@reefclaw/openclaw-plugin 0.1.22 → 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 (65) hide show
  1. package/bridge/bridge.js +110 -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/gateway/tool-discovery.d.ts +1 -1
  7. package/bridge/gateway/tool-discovery.js +4 -0
  8. package/bridge/provider.d.ts +36 -0
  9. package/bridge/providers/connector-update.d.ts +89 -0
  10. package/bridge/providers/connector-update.js +212 -0
  11. package/bridge/providers/emergency-commands.d.ts +36 -0
  12. package/bridge/providers/emergency-commands.js +91 -0
  13. package/bridge/providers/gateway.d.ts +37 -2
  14. package/bridge/providers/gateway.js +164 -9
  15. package/bridge/providers/mock.js +1 -0
  16. package/bridge/providers/onboarding-commands.d.ts +12 -1
  17. package/bridge/providers/onboarding-commands.js +25 -0
  18. package/bridge/types.d.ts +1 -1
  19. package/bridge/types.js +7 -0
  20. package/ccxt/binance-private.js +2 -1
  21. package/ccxt/binance-public.js +6 -1
  22. package/config/agent-config-client.d.ts +5 -2
  23. package/config/agent-config-client.js +13 -0
  24. package/config/agent-config-poller.js +5 -1
  25. package/config/gate-store.d.ts +9 -0
  26. package/config/gate-store.js +17 -2
  27. package/config/plugin-config-io.js +24 -2
  28. package/config/tool-gate.js +1 -0
  29. package/http/keepalive-fetch.d.ts +5 -0
  30. package/http/keepalive-fetch.js +50 -0
  31. package/index.js +73 -6
  32. package/ingest/position-decisions-client.d.ts +6 -0
  33. package/ingest/position-decisions-client.js +27 -9
  34. package/live/approval-lifecycle.d.ts +10 -0
  35. package/live/approval-lifecycle.js +16 -2
  36. package/live/microstructure-assembler.js +11 -2
  37. package/live/proposal-decision-listener.d.ts +21 -0
  38. package/live/proposal-decision-listener.js +39 -0
  39. package/live/proposal-manager.d.ts +12 -0
  40. package/live/proposal-manager.js +47 -0
  41. package/live/stop-watcher.d.ts +16 -1
  42. package/live/stop-watcher.js +48 -8
  43. package/openclaw.plugin.json +2 -1
  44. package/package.json +38 -38
  45. package/persistence/state-manager.d.ts +7 -0
  46. package/persistence/state-manager.js +28 -1
  47. package/simulator/exchange-simulator.d.ts +22 -0
  48. package/simulator/exchange-simulator.js +74 -32
  49. package/tools/audit-bracket-protection.js +11 -7
  50. package/tools/create-order.js +49 -7
  51. package/tools/get-funding-context.js +6 -1
  52. package/tools/get-liquidation-levels.js +5 -1
  53. package/tools/get-liquidation-pulse.js +7 -1
  54. package/tools/get-market-intel.js +2 -1
  55. package/tools/get-relevant-learnings.js +20 -1
  56. package/tools/get-resting-liquidity.js +6 -1
  57. package/tools/get-wave9-status.js +17 -0
  58. package/tools/hl-provision-agent-wallet.js +18 -0
  59. package/tools/hl-submit-agent-approval.d.ts +27 -0
  60. package/tools/hl-submit-agent-approval.js +140 -0
  61. package/tools/intel-api.d.ts +9 -0
  62. package/tools/intel-api.js +32 -1
  63. package/tools/record-position-reviews.js +2 -2
  64. package/tools/scan-pairs.js +20 -11
  65. package/types.d.ts +7 -0
@@ -12,6 +12,34 @@ import { fillMarketOrder, fillLimitOrder, parseSymbol } from './fill-engine.js';
12
12
  import { updateMfe } from '../mfe.js';
13
13
  import { computeInvalidationHit } from '../pinned-plan.js';
14
14
  const TAG = 'simulator';
15
+ /** Canonical key for the per-symbol quote/orderbook caches and for matching a
16
+ * ticker against stored positions/orders.
17
+ *
18
+ * CCXT echoes the VENUE-UNIFIED symbol back from fetchTicker: ask Binance USDM
19
+ * for 'ETH/USDT' and the ticker returns as 'ETH/USDT:USDT'. Positions and
20
+ * orders, however, are stored under whatever form the caller opened them with,
21
+ * so `lastTicker.get(position.symbol)` silently missed for every position held
22
+ * in the un-suffixed form. The consequences were all silent:
23
+ * - getPositions()/computeEquity() fell back to entryPrice, so the mark was
24
+ * FROZEN at entry — the stop-watcher compared a frozen mark, never saw a
25
+ * breach, and the position ran unprotected past its stop indefinitely;
26
+ * - createOrder() threw 'No ticker data for X' on the market leg, so the
27
+ * agent could not close by hand either (both automatic and manual exits
28
+ * were dead at once);
29
+ * - MFE, take-profit legs and resting-limit fills never advanced.
30
+ * Keying both sides through this normalizer makes the caches format-agnostic,
31
+ * which also repairs books already persisted with a mix of both forms.
32
+ *
33
+ * Same regex as webapp/src/lib/symbols.ts normalizeSymbol and
34
+ * plugin/src/venues/symbols.ts stripSettleSuffix — kept inline so the
35
+ * simulator keeps its zero-import-for-hot-path shape. */
36
+ export function tickerKey(symbol) {
37
+ return symbol.replace(/:[A-Z]+$/, '');
38
+ }
39
+ /** True when two symbols denote the same market regardless of settle suffix. */
40
+ function sameMarket(a, b) {
41
+ return tickerKey(a) === tickerKey(b);
42
+ }
15
43
  export class ExchangeSimulator extends EventEmitter {
16
44
  state;
17
45
  lastTicker = new Map();
@@ -112,7 +140,7 @@ export class ExchangeSimulator extends EventEmitter {
112
140
  const walletTotal = this.state.wallet[quote]?.total ?? 0;
113
141
  let positionEquity = 0;
114
142
  for (const pos of this.state.positions) {
115
- const ticker = this.lastTicker.get(pos.symbol);
143
+ const ticker = this.lastTicker.get(tickerKey(pos.symbol));
116
144
  const mark = ticker?.last ?? pos.entryPrice;
117
145
  const entryNotional = pos.entryPrice * pos.quantity;
118
146
  const unrealized = pos.side === 'long'
@@ -182,10 +210,16 @@ export class ExchangeSimulator extends EventEmitter {
182
210
  }
183
211
  getPositions(symbol) {
184
212
  const positions = symbol
185
- ? this.state.positions.filter(p => p.symbol === symbol)
213
+ ? this.state.positions.filter(p => sameMarket(p.symbol, symbol))
186
214
  : this.state.positions;
187
215
  return positions.map(p => {
188
- const ticker = this.lastTicker.get(p.symbol);
216
+ const ticker = this.lastTicker.get(tickerKey(p.symbol));
217
+ // No quote for this symbol means we CANNOT mark this position. Falling
218
+ // back to entryPrice fabricates "flat since entry", which reads as a
219
+ // perfectly healthy position to every consumer — that is how a stop
220
+ // breach stayed invisible for 35h. Keep the fallback (callers need a
221
+ // number) but flag it so the stop-watcher can alarm instead of skipping.
222
+ const markPriceStale = ticker === undefined;
189
223
  const markPrice = ticker?.last ?? p.entryPrice;
190
224
  const notional = p.quantity * markPrice;
191
225
  const pnlMultiplier = p.side === 'long' ? 1 : -1;
@@ -200,6 +234,7 @@ export class ExchangeSimulator extends EventEmitter {
200
234
  contractSize: 1,
201
235
  entryPrice: p.entryPrice,
202
236
  markPrice,
237
+ markPriceStale,
203
238
  notional,
204
239
  unrealizedPnl,
205
240
  percentage,
@@ -233,7 +268,7 @@ export class ExchangeSimulator extends EventEmitter {
233
268
  }
234
269
  getOpenOrders(symbol) {
235
270
  const orders = symbol
236
- ? this.state.openOrders.filter(o => o.symbol === symbol)
271
+ ? this.state.openOrders.filter(o => sameMarket(o.symbol, symbol))
237
272
  : this.state.openOrders;
238
273
  return orders.map(o => this.toCcxtOrder(o));
239
274
  }
@@ -244,10 +279,10 @@ export class ExchangeSimulator extends EventEmitter {
244
279
  // ---- Order book ----
245
280
  /** Cache the latest order book snapshot for a symbol. */
246
281
  updateOrderBook(symbol, orderbook) {
247
- this.lastOrderBook.set(symbol, orderbook);
282
+ this.lastOrderBook.set(tickerKey(symbol), orderbook);
248
283
  }
249
284
  getLastOrderBook(symbol) {
250
- return this.lastOrderBook.get(symbol);
285
+ return this.lastOrderBook.get(tickerKey(symbol));
251
286
  }
252
287
  // ---- Write operations (for tools) ----
253
288
  createOrder(symbol, side, type, amount, price, metadata,
@@ -294,7 +329,7 @@ export class ExchangeSimulator extends EventEmitter {
294
329
  return this.executeMarketFill(order, referencePrice, metadata);
295
330
  }
296
331
  // Market orders fill immediately at current price
297
- const ticker = this.lastTicker.get(symbol);
332
+ const ticker = this.lastTicker.get(tickerKey(symbol));
298
333
  if (!ticker) {
299
334
  throw new Error(`No ticker data for ${symbol}. Call updateTicker() first.`);
300
335
  }
@@ -308,7 +343,7 @@ export class ExchangeSimulator extends EventEmitter {
308
343
  // Limit order — check if it crosses the current price. A stale quote must
309
344
  // not price an immediate cross-fill (same hazard as market fills); the
310
345
  // order RESTS instead and fills on the next fresh tick via updateTicker.
311
- const ticker = this.lastTicker.get(symbol);
346
+ const ticker = this.lastTicker.get(tickerKey(symbol));
312
347
  if (ticker && this.quoteAgeMs(ticker) <= this.maxQuoteAgeMs() && this.shouldFillLimit(order, ticker.last)) {
313
348
  return this.executeLimitFill(order, ticker.last, metadata);
314
349
  }
@@ -341,7 +376,7 @@ export class ExchangeSimulator extends EventEmitter {
341
376
  const cancelled = [];
342
377
  const remaining = [];
343
378
  for (const order of this.state.openOrders) {
344
- if (!symbol || order.symbol === symbol) {
379
+ if (!symbol || sameMarket(order.symbol, symbol)) {
345
380
  order.status = 'canceled';
346
381
  cancelled.push(order);
347
382
  this.pendingOrderMetadata.delete(order.id);
@@ -364,7 +399,7 @@ export class ExchangeSimulator extends EventEmitter {
364
399
  * else, which keeps the normal market-close path byte-identical.
365
400
  */
366
401
  closePosition(symbol, closeReason, referencePrice) {
367
- const position = this.state.positions.find(p => p.symbol === symbol);
402
+ const position = this.state.positions.find(p => sameMarket(p.symbol, symbol));
368
403
  if (!position) {
369
404
  throw new Error(`No open position for ${symbol}`);
370
405
  }
@@ -387,7 +422,7 @@ export class ExchangeSimulator extends EventEmitter {
387
422
  * and getPositions both read metadata.stopPrice, so a moved stop takes
388
423
  * effect on the next watcher tick. Throws if there is no open position. (M9) */
389
424
  updatePositionMetadata(symbol, patch) {
390
- const pos = this.state.positions.find(p => p.symbol === symbol);
425
+ const pos = this.state.positions.find(p => sameMarket(p.symbol, symbol));
391
426
  if (!pos) {
392
427
  throw new Error(`No open position for ${symbol}`);
393
428
  }
@@ -401,7 +436,7 @@ export class ExchangeSimulator extends EventEmitter {
401
436
  }
402
437
  // ---- Ticker updates (checks pending limit fills) ----
403
438
  updateTicker(ticker) {
404
- this.lastTicker.set(ticker.symbol, ticker);
439
+ this.lastTicker.set(tickerKey(ticker.symbol), ticker);
405
440
  this.refreshMfeForSymbol(ticker.symbol, ticker.last);
406
441
  // MFE is refreshed FIRST so the peak this tick reached is recorded before a
407
442
  // target close reads it — otherwise every TP exit would understate its own
@@ -411,7 +446,7 @@ export class ExchangeSimulator extends EventEmitter {
411
446
  const toFill = [];
412
447
  const remaining = [];
413
448
  for (const order of this.state.openOrders) {
414
- if (order.symbol === ticker.symbol && this.shouldFillLimit(order, ticker.last)) {
449
+ if (sameMarket(order.symbol, ticker.symbol) && this.shouldFillLimit(order, ticker.last)) {
415
450
  toFill.push(order);
416
451
  }
417
452
  else {
@@ -449,7 +484,7 @@ export class ExchangeSimulator extends EventEmitter {
449
484
  }
450
485
  }
451
486
  getLastTicker(symbol) {
452
- return this.lastTicker.get(symbol);
487
+ return this.lastTicker.get(tickerKey(symbol));
453
488
  }
454
489
  /**
455
490
  * Take-profit legs — the paper analog of the exchange-native
@@ -483,7 +518,7 @@ export class ExchangeSimulator extends EventEmitter {
483
518
  if (!Number.isFinite(price) || price <= 0)
484
519
  return;
485
520
  // Snapshot: closing mutates state.positions mid-iteration.
486
- const candidates = this.state.positions.filter((p) => p.symbol === symbol);
521
+ const candidates = this.state.positions.filter((p) => sameMarket(p.symbol, symbol));
487
522
  for (const position of candidates) {
488
523
  const target = position.metadata?.targetPrice;
489
524
  if (target === undefined || !Number.isFinite(target) || target <= 0)
@@ -492,17 +527,20 @@ export class ExchangeSimulator extends EventEmitter {
492
527
  if (!breached)
493
528
  continue;
494
529
  // Guard against a re-entrant tick firing a second close on the same
495
- // symbol while the first is still settling.
496
- if (this.takeProfitPending.has(symbol))
530
+ // symbol while the first is still settling. Keyed canonically so the
531
+ // guard still holds when the tick and the position disagree on suffix.
532
+ const pendingKey = tickerKey(position.symbol);
533
+ if (this.takeProfitPending.has(pendingKey))
497
534
  continue;
498
- this.takeProfitPending.add(symbol);
535
+ this.takeProfitPending.add(pendingKey);
499
536
  try {
500
- logger.info(TAG, `TARGET REACHED: ${symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
537
+ logger.info(TAG, `TARGET REACHED: ${position.symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
501
538
  // Target = decision price; the fill engine adds realistic adverse
502
- // slippage on top (see the fill-convention note above).
503
- const order = this.closePosition(symbol, 'exchange_target', target);
539
+ // slippage on top (see the fill-convention note above). Address the
540
+ // close with the POSITION's own symbol, never the ticker's form.
541
+ const order = this.closePosition(position.symbol, 'exchange_target', target);
504
542
  this.emit('target_closed', {
505
- symbol,
543
+ symbol: position.symbol,
506
544
  side: position.side,
507
545
  targetPrice: target,
508
546
  markPrice: price,
@@ -514,10 +552,10 @@ export class ExchangeSimulator extends EventEmitter {
514
552
  catch (err) {
515
553
  // Never let a failed protective close kill the tick loop — the next
516
554
  // tick retries, and the position is still visible to the agent.
517
- logger.error(TAG, `Target close failed for ${symbol}: ${formatError(err)}`);
555
+ logger.error(TAG, `Target close failed for ${position.symbol}: ${formatError(err)}`);
518
556
  }
519
557
  finally {
520
- this.takeProfitPending.delete(symbol);
558
+ this.takeProfitPending.delete(pendingKey);
521
559
  }
522
560
  }
523
561
  }
@@ -530,7 +568,7 @@ export class ExchangeSimulator extends EventEmitter {
530
568
  return;
531
569
  let peakAdvanced = false;
532
570
  for (const p of this.state.positions) {
533
- if (p.symbol !== symbol)
571
+ if (!sameMarket(p.symbol, symbol))
534
572
  continue;
535
573
  const meta = { ...(p.metadata ?? {}) };
536
574
  const priorPeak = meta.mfePeakPrice;
@@ -602,7 +640,7 @@ export class ExchangeSimulator extends EventEmitter {
602
640
  const keptPeak = diskPeak === undefined
603
641
  ? priorPeak
604
642
  : (p.side === 'long' ? Math.max(priorPeak, diskPeak) : Math.min(priorPeak, diskPeak));
605
- const mark = this.lastTicker.get(p.symbol)?.last ?? meta.originalEntryPrice ?? p.entryPrice;
643
+ const mark = this.lastTicker.get(tickerKey(p.symbol))?.last ?? meta.originalEntryPrice ?? p.entryPrice;
606
644
  const out = updateMfe({
607
645
  side: p.side,
608
646
  entryPrice: meta.originalEntryPrice ?? p.entryPrice,
@@ -671,7 +709,7 @@ export class ExchangeSimulator extends EventEmitter {
671
709
  const max = this.maxQuoteAgeMs();
672
710
  if (age <= max)
673
711
  return;
674
- const pos = this.state.positions.find(p => p.symbol === symbol);
712
+ const pos = this.state.positions.find(p => sameMarket(p.symbol, symbol));
675
713
  // The order side reaching here is the one being filled — derive reduce vs
676
714
  // grow from the position side at the call site instead? The market path
677
715
  // calls this before fill with the order side unavailable; use position
@@ -696,8 +734,10 @@ export class ExchangeSimulator extends EventEmitter {
696
734
  : currentPrice >= order.price;
697
735
  }
698
736
  executeMarketFill(order, currentPrice, metadata) {
699
- const position = this.state.positions.find(p => p.symbol === order.symbol) ?? null;
700
- const orderbook = this.lastOrderBook.get(order.symbol) ?? null;
737
+ // Match canonically: a miss here would hand fillMarketOrder position=null,
738
+ // turning a CLOSE into a brand-new opposing position.
739
+ const position = this.state.positions.find(p => sameMarket(p.symbol, order.symbol)) ?? null;
740
+ const orderbook = this.lastOrderBook.get(tickerKey(order.symbol)) ?? null;
701
741
  const realistic = {
702
742
  orderbook,
703
743
  config: this.simulationConfig,
@@ -707,7 +747,7 @@ export class ExchangeSimulator extends EventEmitter {
707
747
  const result = fillMarketOrder(order, currentPrice, this.state.wallet, position, realistic);
708
748
  // Observability for issue #202: stamp the quote's age onto the fill's
709
749
  // execution-quality record so staleness is visible in trade history.
710
- const tickerAtFill = this.lastTicker.get(order.symbol);
750
+ const tickerAtFill = this.lastTicker.get(tickerKey(order.symbol));
711
751
  if (result.executionQuality && tickerAtFill) {
712
752
  result.executionQuality.quoteAgeMs = this.quoteAgeMs(tickerAtFill);
713
753
  }
@@ -725,7 +765,7 @@ export class ExchangeSimulator extends EventEmitter {
725
765
  return ccxtOrder;
726
766
  }
727
767
  executeLimitFill(order, decisionPrice, metadata) {
728
- const position = this.state.positions.find(p => p.symbol === order.symbol) ?? null;
768
+ const position = this.state.positions.find(p => sameMarket(p.symbol, order.symbol)) ?? null;
729
769
  const result = fillLimitOrder(order, this.state.wallet, position, this.simulationConfig, decisionPrice, metadata);
730
770
  const ccxtOrder = this.applyFillResult(result);
731
771
  const eq = result.executionQuality;
@@ -778,7 +818,9 @@ export class ExchangeSimulator extends EventEmitter {
778
818
  return ccxtOrder;
779
819
  }
780
820
  updatePosition(symbol, newPosition) {
781
- const idx = this.state.positions.findIndex(p => p.symbol === symbol);
821
+ // Canonical match: an exact-compare miss here would append a DUPLICATE
822
+ // position row instead of replacing/removing the existing one.
823
+ const idx = this.state.positions.findIndex(p => sameMarket(p.symbol, symbol));
782
824
  if (newPosition) {
783
825
  if (idx >= 0) {
784
826
  this.state.positions[idx] = newPosition;
@@ -566,13 +566,16 @@ async function auditHlLive(adapter) {
566
566
  };
567
567
  }
568
568
  const entries = [];
569
- for (const p of activePositions) {
570
- let symbolOrders;
571
- try {
572
- symbolOrders = await adapter.getOpenOrders(p.symbol);
573
- }
574
- catch (err) {
575
- const m = formatError(err);
569
+ // Fan the per-symbol open-orders reads out (HL has no batched read; the
570
+ // serial loop cost N round-trips per heartbeat), then walk the results in
571
+ // the ORIGINAL position order — the first failing symbol still produces
572
+ // the identical error return, and no verdict logic changes.
573
+ const orderFetches = await Promise.allSettled(activePositions.map((pos) => adapter.getOpenOrders(pos.symbol)));
574
+ for (let i = 0; i < activePositions.length; i++) {
575
+ const p = activePositions[i];
576
+ const fetched = orderFetches[i];
577
+ if (fetched.status === 'rejected') {
578
+ const m = formatError(fetched.reason);
576
579
  logger.warn(TAG, `HL audit skipped — open orders unavailable for ${p.symbol}: ${m}`);
577
580
  return {
578
581
  error: `Exchange data unavailable for ${p.symbol} (Hyperliquid open-orders fetch failed). ` +
@@ -580,6 +583,7 @@ async function auditHlLive(adapter) {
580
583
  `close_position(reason='bracket_integrity') on the basis of this call. Underlying: ${m}`,
581
584
  };
582
585
  }
586
+ const symbolOrders = fetched.value;
583
587
  const ledgerRow = ledger.getBySymbol(p.symbol) ?? null;
584
588
  const rowNonTerminalWithCids = !!ledgerRow && !isTerminalBracketState(ledgerRow.state) && Boolean(ledgerRow.slCid || ledgerRow.tpCid);
585
589
  if (symbolOrders.length === 0 && rowNonTerminalWithCids) {
@@ -1,7 +1,7 @@
1
1
  // Tool: create_order — order execution with real price data + pre-trade risk gate
2
2
  // Readiness gate: BLOCKED unless adapter.readiness === 'READY'.
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { formatError } from '../logger.js';
4
+ import { formatError, logger } from '../logger.js';
5
5
  import { getQuoteBalance, getQuoteWalletBalance } from '../balance-utils.js';
6
6
  import { fetchCurrentPrice, fetchOrderBook, isError } from './helpers.js';
7
7
  import { validateCreateOrder, sanitizeRealizationRule, validateProtectiveGeometry } from './assessment-validation.js';
@@ -875,6 +875,7 @@ export async function createOrderTool(args, deps) {
875
875
  }
876
876
  // In paper mode, fetch latest ticker + order book for realistic fills
877
877
  let ticker = null;
878
+ let livePortfolioPromise;
878
879
  if (!deps.adapter.isLive) {
879
880
  const paperDeps = { binanceApi: deps.binanceApi, simulator: deps.adapter.getSimulator() };
880
881
  const [priceResult] = await Promise.all([
@@ -886,7 +887,14 @@ export async function createOrderTool(args, deps) {
886
887
  ticker = priceResult;
887
888
  }
888
889
  else {
889
- // Live mode: fetch ticker for risk check reference price
890
+ // Live mode: the risk-gate portfolio snapshot (balance + positions) has
891
+ // no dependency on the ticker — start it here so the two exchange
892
+ // round-trips overlap; awaited at the risk check below. The catch-keeper
893
+ // only silences the unhandled rejection an early return would otherwise
894
+ // leave floating; the later await still surfaces the real error.
895
+ livePortfolioPromise = buildPortfolioSnapshotFromAdapter(deps.adapter);
896
+ livePortfolioPromise.catch(() => { });
897
+ // Fetch ticker for risk check reference price
890
898
  const lastPrice = await deps.adapter.getLastPrice(args.symbol);
891
899
  if (lastPrice != null) {
892
900
  ticker = {
@@ -1003,7 +1011,7 @@ export async function createOrderTool(args, deps) {
1003
1011
  consecutiveLosses = computeConsecutiveLosses(simulator.getState().tradeHistory);
1004
1012
  }
1005
1013
  else {
1006
- portfolio = await buildPortfolioSnapshotFromAdapter(deps.adapter);
1014
+ portfolio = await (livePortfolioPromise ?? buildPortfolioSnapshotFromAdapter(deps.adapter));
1007
1015
  // In live mode, consecutive losses would come from intelligence DB — use 0 for now
1008
1016
  }
1009
1017
  // Bracket enforcement only applies in live mode when the feature is enabled.
@@ -1083,14 +1091,48 @@ export async function createOrderTool(args, deps) {
1083
1091
  typeof args.confluence_score === 'number');
1084
1092
  const proposalPathWired = !!(deps.proposalManager && deps.userId);
1085
1093
  const approvalMode = deps.approvalMode ?? 'off';
1094
+ // per_trade + proposal path unwired: FAIL CLOSED.
1095
+ //
1096
+ // The operator asked for a human gate on every entry. If we can't deliver
1097
+ // the proposal (no ingest token / no REEFCLAW_USER_ID), the honest failure
1098
+ // is "no trade", not "trade without the gate". This branch previously fell
1099
+ // through to the normal fire path so the agent was never trapped in an
1100
+ // unfireable state — but that turned a misconfiguration into a live order
1101
+ // the operator never approved, silently, with only a boot-time warning as
1102
+ // the signal. A silent bypass of an explicitly-requested safety gate is the
1103
+ // one outcome this feature exists to prevent (Locked Decision #2: never
1104
+ // silent fire). Credentials drift for ordinary reasons — token rotation
1105
+ // touches four files (docs/CLAUDE/token-rotation.md), and a freshly
1106
+ // provisioned box can boot before they land — so this is a reachable state,
1107
+ // not a theoretical one.
1108
+ //
1109
+ // Halting entries is the safe direction: exits, closes, stops and every
1110
+ // operator emergency control are unaffected (they don't route through the
1111
+ // approval branch), so a mis-wired box can still protect and unwind an open
1112
+ // book — it just can't open new risk without the gate the operator asked
1113
+ // for. Logged at ERROR on EVERY occurrence, not once at boot, so the signal
1114
+ // is present at the time the trade is refused.
1115
+ if (approvalMode === 'per_trade' && !proposalPathWired) {
1116
+ logger.error('create-order', `approval.mode=per_trade but the proposal path is not wired — REFUSING to fire ` +
1117
+ `${args.symbol} ${side} (fail-closed). Missing plugin connectionToken/WEBAPP_INGEST_TOKEN ` +
1118
+ `and/or REEFCLAW_USER_ID. Restore the credentials, or set approval.mode=off to trade ` +
1119
+ `autonomously again.`);
1120
+ return {
1121
+ error: 'Order refused: approval mode is per_trade but this box cannot deliver the proposal ' +
1122
+ 'to the operator (missing connection token and/or REEFCLAW_USER_ID), so there is no ' +
1123
+ 'way for the operator to approve it. Refusing to fire un-approved — this is fail-closed ' +
1124
+ 'by design, not a transient error. Tell the operator: either restore the plugin ' +
1125
+ 'connection credentials, or set approval.mode=off to resume autonomous trading. ' +
1126
+ 'Do not retry until they confirm one of those. Closing positions, stops and brackets ' +
1127
+ 'are unaffected.',
1128
+ };
1129
+ }
1086
1130
  // per_trade mode: propose and EARLY-RETURN. The agent's call doesn't fire;
1087
1131
  // the operator approves and ProposalDecisionListener fires via this same
1088
1132
  // tool with proposalManager omitted (which takes the real path below).
1089
1133
  //
1090
- // If proposalManager isn't wired (operator set approval.mode=per_trade in
1091
- // config but ingest creds aren't present), we fall through to the normal
1092
- // fire path — the startup warning is the operator's signal. We never trap
1093
- // the agent inside an unfireable per_trade branch.
1134
+ // NOTE the listener's own fire passes NO approvalMode (defaults to 'off')
1135
+ // and no proposalManager, so it never re-enters this branch.
1094
1136
  if (approvalMode === 'per_trade' && proposalPathWired) {
1095
1137
  if (!argsMetadataComplete) {
1096
1138
  return {
@@ -12,5 +12,10 @@
12
12
  // FUNDING_OVERLAY flag is off or the symbol has < 100 30d samples.
13
13
  import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
14
14
  export async function getFundingContextTool(args, deps) {
15
- return fetchIntelApi(`/api/funding/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps);
15
+ // Server-side percentile context already has a 15-min TTL — 60s here just
16
+ // collapses same-heartbeat repeats.
17
+ return fetchIntelApi(`/api/funding/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps, {
18
+ cacheTtlMs: 60_000,
19
+ timeoutMs: 10_000,
20
+ });
16
21
  }
@@ -3,5 +3,9 @@
3
3
  import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
4
4
  export async function getLiquidationLevelsTool(args, deps) {
5
5
  const hours = args.hours ?? 24;
6
- return fetchIntelApi(`/api/liquidation-levels/${enc(resolveIntelSymbol(deps, args.symbol))}?hours=${hours}`, deps);
6
+ // Historical clusters move slowly — 30s TTL collapses per-position repeats.
7
+ return fetchIntelApi(`/api/liquidation-levels/${enc(resolveIntelSymbol(deps, args.symbol))}?hours=${hours}`, deps, {
8
+ cacheTtlMs: 30_000,
9
+ timeoutMs: 10_000,
10
+ });
7
11
  }
@@ -13,7 +13,13 @@ export async function getLiquidationPulseTool(args, deps) {
13
13
  params.set('symbol', resolveIntelSymbol(deps, args.symbol));
14
14
  const windowSeconds = clamp(args.window_seconds ?? 60, 5, 300);
15
15
  params.set('window_seconds', String(windowSeconds));
16
- return fetchIntelApi(`/api/liquidation-pulse?${params.toString()}`, deps);
16
+ // 10s TTL: short enough that an active_cascade classification is never
17
+ // stale-read into the override decision (cascades persist 60-180s), long
18
+ // enough to collapse the per-position repeats within one heartbeat.
19
+ return fetchIntelApi(`/api/liquidation-pulse?${params.toString()}`, deps, {
20
+ cacheTtlMs: 10_000,
21
+ timeoutMs: 10_000,
22
+ });
17
23
  }
18
24
  function clamp(n, lo, hi) {
19
25
  if (!Number.isFinite(n))
@@ -12,6 +12,7 @@
12
12
  // unchanged. This bounds the threat — it can't make the agent place a naked
13
13
  // order (the create_order gate still applies) — and makes injected directives
14
14
  // far less likely to be followed.
15
+ import { keepAliveFetch } from '../http/keepalive-fetch.js';
15
16
  /** Categories whose payloads contain attacker-influenceable free text. */
16
17
  const UNTRUSTED_TEXT_CATEGORIES = new Set(['news', 'social', 'calendar']);
17
18
  const MAX_TEXT_LEN = 2000;
@@ -88,7 +89,7 @@ export async function getMarketIntelTool(args, deps) {
88
89
  url.searchParams.set('symbols', symbols.join(','));
89
90
  }
90
91
  try {
91
- const res = await fetch(url.toString(), {
92
+ const res = await keepAliveFetch(url.toString(), {
92
93
  headers: { Authorization: `Bearer ${connectionToken}` },
93
94
  signal: AbortSignal.timeout(10_000),
94
95
  });
@@ -15,6 +15,13 @@
15
15
  import { logger, formatError } from '../logger.js';
16
16
  const TAG = 'get-relevant-learnings';
17
17
  const VALID_APPLIES_AT = new Set(['entry', 'heartbeat', 'close']);
18
+ // Curated learnings change on operator-curation timescales, but the agent is
19
+ // instructed to read them at several points per heartbeat — and a looping
20
+ // model can spam identical calls (observed live 2026-08-04: ~230 identical
21
+ // calls in one beat drove a 272k-token context overflow). A short TTL keyed
22
+ // on the exact query bounds both.
23
+ const CACHE_TTL_MS = 30_000;
24
+ const cache = new Map();
18
25
  export async function getRelevantLearningsTool(args, deps) {
19
26
  if (!deps.decisionsClient || !deps.userId) {
20
27
  // Ingest not wired — return empty, callable. Same shape as
@@ -32,6 +39,11 @@ export async function getRelevantLearningsTool(args, deps) {
32
39
  error: "applies_at is required and must be one of: 'entry', 'heartbeat', 'close'.",
33
40
  };
34
41
  }
42
+ const cacheKey = `${deps.userId}:${args.applies_at}:${args.setup_type ?? ''}:${args.regime ?? ''}:${args.verdict ?? ''}`;
43
+ const hit = cache.get(cacheKey);
44
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
45
+ return structuredClone(hit.result);
46
+ }
35
47
  let response;
36
48
  try {
37
49
  response = await deps.decisionsClient.getRelevantLearnings(deps.userId, {
@@ -56,10 +68,17 @@ export async function getRelevantLearningsTool(args, deps) {
56
68
  note: 'Webapp /api/internal/learnings returned no result; proceeding without curated learnings.',
57
69
  };
58
70
  }
59
- return {
71
+ const result = {
60
72
  ok: true,
61
73
  learnings: response.learnings,
62
74
  total_candidates: response.totalCandidates,
63
75
  returned_count: response.returnedCount,
64
76
  };
77
+ cache.set(cacheKey, { at: Date.now(), result });
78
+ if (cache.size > 64) {
79
+ const oldest = cache.keys().next().value;
80
+ if (oldest !== undefined)
81
+ cache.delete(oldest);
82
+ }
83
+ return structuredClone(result);
65
84
  }
@@ -7,5 +7,10 @@
7
7
  // `microstructure.bandedLiquidity` flag is off (= columns are NULL).
8
8
  import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
9
9
  export async function getRestingLiquidityTool(args, deps) {
10
- return fetchIntelApi(`/api/resting-liquidity/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps);
10
+ // Cached: the microstructure assembler hits the same endpoint on the review
11
+ // path each heartbeat; advisory read, so a short TTL + tight timeout.
12
+ return fetchIntelApi(`/api/resting-liquidity/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps, {
13
+ cacheTtlMs: 15_000,
14
+ timeoutMs: 10_000,
15
+ });
11
16
  }
@@ -695,6 +695,23 @@ export async function getWave9StatusTool(deps) {
695
695
  const reversalDue = position
696
696
  ? (position.side === 'long' ? daily.exitLong : daily.exitShort)
697
697
  : false;
698
+ // Quiet-row compaction: no candidate and no held position — the evidence
699
+ // numerics (returns / ATR / reference close) are ~740B per symbol the
700
+ // agent never acts on; × 8 symbols × every heartbeat this was ~68% of
701
+ // the whole status payload. Rows with entries or a position keep the
702
+ // full shape unchanged. Deliberately NOT gated on daily.exitLong/
703
+ // exitShort: those flags are set for most symbols on any
704
+ // negative-momentum day, and with no held position there is nothing to
705
+ // exit — the first shipped version kept them and saved nothing
706
+ // (verified live 2026-08-04: 72 currentReturn mentions per beat).
707
+ if (entries.length === 0 && !position) {
708
+ return {
709
+ symbol,
710
+ capDecision: { status: 'not_candidate', reason: 'no_completed_daily_zero_cross' },
711
+ entries: [],
712
+ reversal: { due: false, timing: 'not_due' },
713
+ };
714
+ }
698
715
  return {
699
716
  symbol,
700
717
  currentReturn: daily.currentReturn,
@@ -96,6 +96,24 @@ export async function hlProvisionAgentWalletTool(args, deps) {
96
96
  const derived = await deriveAddressFromPrivateKey(existingKey);
97
97
  if (derived.ok) {
98
98
  const masterMatches = existingMaster != null && existingMaster.toLowerCase() === walletAddress.toLowerCase();
99
+ // ★ Honour a CHANGED network on the resume path. The same keypair is
100
+ // valid on both Hyperliquid networks, so switching mainnet<->testnet
101
+ // must not require regenerating the wallet — but the stored flag has to
102
+ // follow, or the box boots against the network the operator did NOT
103
+ // pick. Before this, unticking "Use Hyperliquid testnet" after
104
+ // provisioning silently kept testnet:true, and the approval was signed
105
+ // for Testnet while the operator believed they were on mainnet
106
+ // (observed live 2026-08-03).
107
+ if (masterMatches && existingTestnet !== testnet) {
108
+ try {
109
+ updatePluginConfig({ exchange: buildVenueExchangeConfig(existingExchange, 'hyperliquid', {}, testnet) }, deps.configPath);
110
+ existingTestnet = testnet;
111
+ logger.info(TAG, `network switched to ${testnet ? 'TESTNET' : 'MAINNET'} (same agent wallet)`);
112
+ }
113
+ catch (err) {
114
+ logger.warn(TAG, `could not persist network change: ${err instanceof Error ? err.message : String(err)}`);
115
+ }
116
+ }
99
117
  return {
100
118
  ok: true,
101
119
  message: masterMatches
@@ -0,0 +1,27 @@
1
+ export interface HlSubmitAgentApprovalArgs {
2
+ /** The approveAgent action, byte-identical to what the wallet signed. */
3
+ action?: Record<string, unknown>;
4
+ /** Outer nonce — for user-signed actions it must equal action.nonce. */
5
+ nonce?: number;
6
+ signature?: {
7
+ r?: unknown;
8
+ s?: unknown;
9
+ v?: unknown;
10
+ };
11
+ /** Tool-discovery probe — returns immediately, no config or network. */
12
+ probe?: boolean;
13
+ }
14
+ export interface HlSubmitAgentApprovalResult {
15
+ ok: boolean;
16
+ message: string;
17
+ /** Hyperliquid's own status string when it answered ('ok' | 'err'). */
18
+ hlStatus?: string;
19
+ /** Which network the SIGNED action targeted. */
20
+ chain?: 'Mainnet' | 'Testnet';
21
+ agentAddress?: string;
22
+ }
23
+ export interface HlSubmitAgentApprovalDeps {
24
+ configPath?: string;
25
+ fetchImpl?: typeof fetch;
26
+ }
27
+ export declare function hlSubmitAgentApprovalTool(args: HlSubmitAgentApprovalArgs, deps?: HlSubmitAgentApprovalDeps): Promise<HlSubmitAgentApprovalResult>;