@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.
Files changed (57) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.js +14 -2
  3. package/bridge/gateway/heartbeat-cron.js +30 -7
  4. package/bridge/gateway/poller.d.ts +5 -0
  5. package/bridge/gateway/poller.js +9 -0
  6. package/bridge/provider.d.ts +15 -0
  7. package/bridge/providers/connector-update.d.ts +89 -0
  8. package/bridge/providers/connector-update.js +212 -0
  9. package/bridge/providers/emergency-commands.d.ts +36 -0
  10. package/bridge/providers/emergency-commands.js +91 -0
  11. package/bridge/providers/gateway.d.ts +26 -1
  12. package/bridge/providers/gateway.js +159 -8
  13. package/bridge/providers/mock.js +1 -0
  14. package/bridge/types.d.ts +1 -1
  15. package/bridge/types.js +5 -0
  16. package/ccxt/binance-private.js +2 -1
  17. package/ccxt/binance-public.js +6 -1
  18. package/config/agent-config-client.d.ts +5 -2
  19. package/config/agent-config-client.js +13 -0
  20. package/config/agent-config-poller.js +5 -1
  21. package/config/gate-store.d.ts +9 -0
  22. package/config/gate-store.js +17 -2
  23. package/config/plugin-config-io.js +24 -2
  24. package/http/keepalive-fetch.d.ts +5 -0
  25. package/http/keepalive-fetch.js +50 -0
  26. package/index.js +48 -6
  27. package/ingest/position-decisions-client.d.ts +6 -0
  28. package/ingest/position-decisions-client.js +27 -9
  29. package/live/approval-lifecycle.d.ts +10 -0
  30. package/live/approval-lifecycle.js +16 -2
  31. package/live/microstructure-assembler.js +11 -2
  32. package/live/proposal-decision-listener.d.ts +21 -0
  33. package/live/proposal-decision-listener.js +39 -0
  34. package/live/proposal-manager.d.ts +12 -0
  35. package/live/proposal-manager.js +47 -0
  36. package/live/stop-watcher.d.ts +16 -1
  37. package/live/stop-watcher.js +48 -8
  38. package/openclaw.plugin.json +1 -1
  39. package/package.json +38 -38
  40. package/persistence/state-manager.d.ts +7 -0
  41. package/persistence/state-manager.js +28 -1
  42. package/simulator/exchange-simulator.d.ts +22 -0
  43. package/simulator/exchange-simulator.js +74 -32
  44. package/tools/audit-bracket-protection.js +11 -7
  45. package/tools/create-order.js +49 -7
  46. package/tools/get-funding-context.js +6 -1
  47. package/tools/get-liquidation-levels.js +5 -1
  48. package/tools/get-liquidation-pulse.js +7 -1
  49. package/tools/get-market-intel.js +2 -1
  50. package/tools/get-relevant-learnings.js +20 -1
  51. package/tools/get-resting-liquidity.js +6 -1
  52. package/tools/get-wave9-status.js +17 -0
  53. package/tools/intel-api.d.ts +9 -0
  54. package/tools/intel-api.js +32 -1
  55. package/tools/record-position-reviews.js +2 -2
  56. package/tools/scan-pairs.js +20 -11
  57. 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
- 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(),
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',
@@ -813,7 +813,8 @@ export class BinancePrivateApi {
813
813
  const raw = await this.exchange.fetchTicker(symbol);
814
814
  noteSuccess();
815
815
  return {
816
- symbol: raw.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,
@@ -86,7 +86,12 @@ export class BinancePublicApi {
86
86
  const raw = await this.exchange.fetchTicker(symbol);
87
87
  noteSuccess();
88
88
  return {
89
- symbol: raw.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`). Both
8
- * ride the same four-stage `off shadow → observe → enforce` ladder. */
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
- if (applyIfAcceptable(fetched, 'network')) {
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;
@@ -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
  }
@@ -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>;