@reefclaw/openclaw-plugin 0.1.23 → 0.1.24
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.js +14 -2
- package/bridge/gateway/heartbeat-cron.js +30 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -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/types.d.ts +1 -1
- package/bridge/types.js +5 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +5 -2
- package/config/agent-config-client.js +13 -0
- package/config/agent-config-poller.js +5 -1
- package/config/gate-store.d.ts +9 -0
- package/config/gate-store.js +17 -2
- package/config/plugin-config-io.js +24 -2
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +48 -6
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- 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/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/simulator/exchange-simulator.d.ts +22 -0
- package/simulator/exchange-simulator.js +74 -32
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/create-order.js +49 -7
- 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/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/scan-pairs.js +20 -11
- package/types.d.ts +7 -0
|
@@ -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
package/bridge/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type { Channel, RequestFrame, ResponseFrame, EventFrame, Frame, Emergency
|
|
|
3
3
|
export { VALID_CHANNELS, VALID_EMERGENCY_ACTIONS } from '@reefclaw/shared';
|
|
4
4
|
type EventFrame = _EventFrame;
|
|
5
5
|
/** Methods the skill accepts from the relay (browser → skill) */
|
|
6
|
-
export declare const ALLOWED_METHODS: readonly ["emergency.kill", "emergency.flatten", "emergency.pause", "emergency.resume", "reconcile", "chat.send", "skill.update", "close_position", "set_trading_mode", "set_exchange_credentials", "test_exchange_credentials", "clear_exchange_credentials", "hl_provision_agent_wallet", "hl_agent_wallet_status", "hl_submit_agent_approval", "get_bracket_config", "set_bracket_requirement"];
|
|
6
|
+
export declare const ALLOWED_METHODS: readonly ["emergency.kill", "emergency.flatten", "emergency.pause", "emergency.resume", "reconcile", "chat.send", "skill.update", "close_position", "set_trading_mode", "set_exchange_credentials", "test_exchange_credentials", "clear_exchange_credentials", "hl_provision_agent_wallet", "hl_agent_wallet_status", "hl_submit_agent_approval", "get_bracket_config", "set_bracket_requirement", "connector.update"];
|
|
7
7
|
/** Subset of ALLOWED_METHODS that require operator.write scope. The bridge
|
|
8
8
|
* enforces this before dispatching — a session without the scope gets a
|
|
9
9
|
* 403 error. PR2 ships the scope as session-wide (inherited from the Clerk
|
package/bridge/types.js
CHANGED
|
@@ -25,6 +25,10 @@ export const ALLOWED_METHODS = [
|
|
|
25
25
|
// Bracket-orders config (Phase 3.5b) — operator.write-gated.
|
|
26
26
|
'get_bracket_config',
|
|
27
27
|
'set_bracket_requirement',
|
|
28
|
+
// Operator-triggered connector update. Runs a FIXED command on the box over
|
|
29
|
+
// the gateway PTY — carries no command/version/args field by design (see the
|
|
30
|
+
// security note in providers/connector-update.ts). operator.write-gated.
|
|
31
|
+
'connector.update',
|
|
28
32
|
];
|
|
29
33
|
/** Subset of ALLOWED_METHODS that require operator.write scope. The bridge
|
|
30
34
|
* enforces this before dispatching — a session without the scope gets a
|
|
@@ -42,6 +46,7 @@ export const OPERATOR_WRITE_METHODS = new Set([
|
|
|
42
46
|
'hl_submit_agent_approval',
|
|
43
47
|
'get_bracket_config',
|
|
44
48
|
'set_bracket_requirement',
|
|
49
|
+
'connector.update',
|
|
45
50
|
'emergency.kill',
|
|
46
51
|
'emergency.flatten',
|
|
47
52
|
'emergency.pause',
|
package/ccxt/binance-private.js
CHANGED
|
@@ -813,7 +813,8 @@ export class BinancePrivateApi {
|
|
|
813
813
|
const raw = await this.exchange.fetchTicker(symbol);
|
|
814
814
|
noteSuccess();
|
|
815
815
|
return {
|
|
816
|
-
symbol
|
|
816
|
+
// Echo the REQUESTED symbol — see BinancePublicApi.fetchTicker.
|
|
817
|
+
symbol,
|
|
817
818
|
last: raw.last ?? 0,
|
|
818
819
|
bid: raw.bid ?? 0,
|
|
819
820
|
ask: raw.ask ?? 0,
|
package/ccxt/binance-public.js
CHANGED
|
@@ -86,7 +86,12 @@ export class BinancePublicApi {
|
|
|
86
86
|
const raw = await this.exchange.fetchTicker(symbol);
|
|
87
87
|
noteSuccess();
|
|
88
88
|
return {
|
|
89
|
-
symbol
|
|
89
|
+
// Echo the REQUESTED symbol, not ccxt's unified form. ccxt rewrites
|
|
90
|
+
// 'ETH/USDT' → 'ETH/USDT:USDT' on USDM futures; returning that made
|
|
91
|
+
// callers key per-symbol caches under a name the caller never asked
|
|
92
|
+
// for (see the simulator's tickerKey note). Same invariant that
|
|
93
|
+
// intel-public.ts fetchTicker already documents.
|
|
94
|
+
symbol,
|
|
90
95
|
last: raw.last ?? 0,
|
|
91
96
|
bid: raw.bid ?? 0,
|
|
92
97
|
ask: raw.ask ?? 0,
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* never whole-payload rejection. Gates are added one at a time
|
|
5
5
|
* (TOOL_DISTRIBUTION_ARCHITECTURE.md §11 step 3): `exitGate` (slice 2) →
|
|
6
6
|
* `positionReviewMode` (slice 3, the Position Decision Journal
|
|
7
|
-
* heartbeat-mandate + superset gate, file key `positionReview.mode`)
|
|
8
|
-
*
|
|
7
|
+
* heartbeat-mandate + superset gate, file key `positionReview.mode`) →
|
|
8
|
+
* `approvalMode` (slice 4, per-trade operator approval, file key
|
|
9
|
+
* `approval.mode`). The first two ride the same four-stage
|
|
10
|
+
* `off → shadow → observe → enforce` ladder; approvalMode has its own. */
|
|
9
11
|
export interface AgentGates {
|
|
10
12
|
exitGate?: 'off' | 'shadow' | 'observe' | 'enforce';
|
|
11
13
|
positionReviewMode?: 'off' | 'shadow' | 'observe' | 'enforce';
|
|
14
|
+
approvalMode?: 'off' | 'per_trade';
|
|
12
15
|
}
|
|
13
16
|
/** Server-resolved entitlement verdict (webapp lib/entitlements.ts, computed
|
|
14
17
|
* from the users row and delivered on the config channel). The plugin NEVER
|
|
@@ -23,6 +23,15 @@ const TAG = 'agent-config';
|
|
|
23
23
|
* because exitGate + positionReviewMode use identical values; a future gate
|
|
24
24
|
* with a different enum gets its own constant. */
|
|
25
25
|
const MODE_LADDER_VALUES = new Set(['off', 'shadow', 'observe', 'enforce']);
|
|
26
|
+
/** approvalMode's own enum — deliberately NOT the four-stage ladder.
|
|
27
|
+
*
|
|
28
|
+
* ★ `shadow` is absent on purpose. Shadow proposal telemetry is driven by the
|
|
29
|
+
* APPROVAL_SHADOW_MODE systemd env flag, which is boot-snapshotted and local
|
|
30
|
+
* to the box; it is not a central value. Accepting 'shadow' here would let a
|
|
31
|
+
* central push silently enable telemetry writes the operator never configured,
|
|
32
|
+
* and would collide with the env flag's precedence. Central can express
|
|
33
|
+
* exactly the two states that change trading behaviour. */
|
|
34
|
+
const APPROVAL_MODE_VALUES = new Set(['off', 'per_trade']);
|
|
26
35
|
const ENTITLEMENT_STATES = new Set([
|
|
27
36
|
'active',
|
|
28
37
|
'trialing',
|
|
@@ -112,6 +121,10 @@ function validateGates(raw) {
|
|
|
112
121
|
if (typeof positionReviewMode === 'string' && MODE_LADDER_VALUES.has(positionReviewMode)) {
|
|
113
122
|
gates.positionReviewMode = positionReviewMode;
|
|
114
123
|
}
|
|
124
|
+
const approvalMode = obj.approvalMode;
|
|
125
|
+
if (typeof approvalMode === 'string' && APPROVAL_MODE_VALUES.has(approvalMode)) {
|
|
126
|
+
gates.approvalMode = approvalMode;
|
|
127
|
+
}
|
|
115
128
|
return gates;
|
|
116
129
|
}
|
|
117
130
|
/** Version-monotonic acceptance (basic rollback/replay protection): a fetched
|
|
@@ -65,7 +65,11 @@ export function startAgentConfigPoller(opts) {
|
|
|
65
65
|
});
|
|
66
66
|
if (fetched) {
|
|
67
67
|
loggedFetchFailure = false;
|
|
68
|
-
|
|
68
|
+
// Only touch the cache file when the config actually changed —
|
|
69
|
+
// isAcceptableVersion accepts equal versions, so this used to do a
|
|
70
|
+
// blocking write+rename every 60s poll forever.
|
|
71
|
+
const prevJson = JSON.stringify(current);
|
|
72
|
+
if (applyIfAcceptable(fetched, 'network') && JSON.stringify(fetched) !== prevJson) {
|
|
69
73
|
writeCachedConfig(fetched, opts.cachePath);
|
|
70
74
|
}
|
|
71
75
|
return;
|
package/config/gate-store.d.ts
CHANGED
|
@@ -9,6 +9,15 @@ declare class GateStore {
|
|
|
9
9
|
/** The central positionReview.mode, or null when central has no value (or the
|
|
10
10
|
* kill-switch is on) — null tells the reader to fall back to the file. */
|
|
11
11
|
getPositionReviewMode(): AgentGates['positionReviewMode'] | null;
|
|
12
|
+
/** The central approval.mode, or null when central has no value (or the
|
|
13
|
+
* kill-switch is on) — null tells the reader to fall back to the file.
|
|
14
|
+
*
|
|
15
|
+
* ★ Central can only ever say 'off' or 'per_trade'. It cannot enable shadow
|
|
16
|
+
* telemetry (that's the local APPROVAL_SHADOW_MODE env flag) and it cannot
|
|
17
|
+
* weaken the hardcoded safety floor — per_trade only ADDS a gate, and 'off'
|
|
18
|
+
* is the pre-existing autonomous behaviour, so neither value can leave a
|
|
19
|
+
* position unprotected. */
|
|
20
|
+
getApprovalMode(): AgentGates['approvalMode'] | null;
|
|
12
21
|
/** Test-only. */
|
|
13
22
|
__reset(): void;
|
|
14
23
|
}
|
package/config/gate-store.js
CHANGED
|
@@ -30,11 +30,13 @@ class GateStore {
|
|
|
30
30
|
apply(gates) {
|
|
31
31
|
const next = gates ?? {};
|
|
32
32
|
const changed = next.exitGate !== this.gates.exitGate ||
|
|
33
|
-
next.positionReviewMode !== this.gates.positionReviewMode
|
|
33
|
+
next.positionReviewMode !== this.gates.positionReviewMode ||
|
|
34
|
+
next.approvalMode !== this.gates.approvalMode;
|
|
34
35
|
this.gates = { ...next };
|
|
35
36
|
if (changed) {
|
|
36
37
|
logger.info(TAG, `applied central gates: exitGate=${next.exitGate ?? UNSET} ` +
|
|
37
|
-
`positionReviewMode=${next.positionReviewMode ?? UNSET}`
|
|
38
|
+
`positionReviewMode=${next.positionReviewMode ?? UNSET} ` +
|
|
39
|
+
`approvalMode=${next.approvalMode ?? UNSET}`);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
/** The central exitGate mode, or null when central has no value (or the
|
|
@@ -51,6 +53,19 @@ class GateStore {
|
|
|
51
53
|
return null;
|
|
52
54
|
return this.gates.positionReviewMode ?? null;
|
|
53
55
|
}
|
|
56
|
+
/** The central approval.mode, or null when central has no value (or the
|
|
57
|
+
* kill-switch is on) — null tells the reader to fall back to the file.
|
|
58
|
+
*
|
|
59
|
+
* ★ Central can only ever say 'off' or 'per_trade'. It cannot enable shadow
|
|
60
|
+
* telemetry (that's the local APPROVAL_SHADOW_MODE env flag) and it cannot
|
|
61
|
+
* weaken the hardcoded safety floor — per_trade only ADDS a gate, and 'off'
|
|
62
|
+
* is the pre-existing autonomous behaviour, so neither value can leave a
|
|
63
|
+
* position unprotected. */
|
|
64
|
+
getApprovalMode() {
|
|
65
|
+
if (!centralGatesEnabled())
|
|
66
|
+
return null;
|
|
67
|
+
return this.gates.approvalMode ?? null;
|
|
68
|
+
}
|
|
54
69
|
/** Test-only. */
|
|
55
70
|
__reset() {
|
|
56
71
|
this.gates = {};
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// operator-only tool gated on dashboard provenance (verifyOperatorProvenance,
|
|
11
11
|
// audit F12) — the agent cannot reach these writes conversationally. The file
|
|
12
12
|
// is the plugin's OWN config store; nothing here touches OpenClaw's config.
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { logger } from '../logger.js';
|
|
@@ -82,17 +82,39 @@ export function loadMicroLiveConfig(path) {
|
|
|
82
82
|
catch { /* best-effort — adapter default applies */ }
|
|
83
83
|
return undefined;
|
|
84
84
|
}
|
|
85
|
+
// mtime(ns)+size memo: several hot paths re-read this file per tool call
|
|
86
|
+
// (approval mode, bracket mode/requirements, exit gate, review mode, the
|
|
87
|
+
// microstructure flag × N symbols on the review path). A write always bumps
|
|
88
|
+
// mtime, so the documented per-call hot-reload semantics are preserved
|
|
89
|
+
// exactly — an unchanged file just costs one stat instead of read+parse.
|
|
90
|
+
// structuredClone on both sides keeps today's fresh-object-per-call contract.
|
|
91
|
+
const readMemo = new Map();
|
|
85
92
|
/** Read the config file. Returns `{}` if the file doesn't exist.
|
|
86
93
|
* Throws if the file exists but is unreadable or not valid JSON — callers
|
|
87
94
|
* should treat that as an abort signal, not silently overwrite. */
|
|
88
95
|
export function readPluginConfig(path = defaultConfigPath()) {
|
|
89
|
-
if (!existsSync(path))
|
|
96
|
+
if (!existsSync(path)) {
|
|
97
|
+
readMemo.delete(path);
|
|
90
98
|
return {};
|
|
99
|
+
}
|
|
100
|
+
let stat;
|
|
101
|
+
try {
|
|
102
|
+
const s = statSync(path, { bigint: true });
|
|
103
|
+
stat = { mtimeNs: s.mtimeNs, size: s.size };
|
|
104
|
+
const hit = readMemo.get(path);
|
|
105
|
+
if (hit && hit.mtimeNs === stat.mtimeNs && hit.size === stat.size) {
|
|
106
|
+
return structuredClone(hit.parsed);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch { /* stat raced a delete — fall through to the plain read */ }
|
|
91
110
|
const raw = readFileSync(path, 'utf-8');
|
|
92
111
|
const parsed = JSON.parse(raw);
|
|
93
112
|
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
94
113
|
throw new Error(`plugin-config.json root is not an object`);
|
|
95
114
|
}
|
|
115
|
+
if (stat) {
|
|
116
|
+
readMemo.set(path, { ...stat, parsed: structuredClone(parsed) });
|
|
117
|
+
}
|
|
96
118
|
return parsed;
|
|
97
119
|
}
|
|
98
120
|
/** Apply a patch on top of the existing file and write atomically.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
/** Drop-in fetch with connection keep-alive; falls back to global fetch when
|
|
3
|
+
* undici is unavailable, and defers to globalThis.fetch whenever it has been
|
|
4
|
+
* replaced (mocks/instrumentation). */
|
|
5
|
+
export declare function keepAliveFetch(url: string, init?: RequestInit): Promise<Response>;
|