@reefclaw/openclaw-plugin 0.1.12 → 0.1.14

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 (67) hide show
  1. package/bridge/gateway/event-parser.d.ts +19 -0
  2. package/bridge/gateway/event-parser.js +52 -0
  3. package/bridge/gateway/gateway-config.d.ts +16 -5
  4. package/bridge/gateway/gateway-config.js +68 -12
  5. package/bridge/gateway/heartbeat-cron.d.ts +1 -0
  6. package/bridge/gateway/heartbeat-cron.js +22 -1
  7. package/bridge/gateway/poller.js +18 -8
  8. package/bridge/providers/emergency-commands.d.ts +9 -1
  9. package/bridge/providers/emergency-commands.js +38 -1
  10. package/bridge/providers/gateway.d.ts +72 -1
  11. package/bridge/providers/gateway.js +241 -29
  12. package/bridge/providers/onboarding-commands.d.ts +8 -0
  13. package/bridge/providers/onboarding-commands.js +4 -4
  14. package/bridge/types.d.ts +30 -0
  15. package/bridge/utils/identity-name.d.ts +24 -0
  16. package/bridge/utils/identity-name.js +54 -0
  17. package/ccxt/binance-public.d.ts +21 -7
  18. package/ccxt/binance-public.js +70 -6
  19. package/config/operator-provenance.d.ts +6 -0
  20. package/config/operator-provenance.js +50 -0
  21. package/config/plugin-config-io.d.ts +15 -1
  22. package/config/plugin-config-io.js +24 -0
  23. package/index.js +216 -173
  24. package/ingest/event-loop-monitor.d.ts +11 -0
  25. package/ingest/event-loop-monitor.js +113 -0
  26. package/ingest/position-auto-capture.d.ts +5 -0
  27. package/ingest/position-auto-capture.js +14 -5
  28. package/ingest/readiness-reporter.d.ts +17 -6
  29. package/ingest/readiness-reporter.js +88 -9
  30. package/ingest/skill-version-reader.d.ts +16 -0
  31. package/ingest/skill-version-reader.js +64 -0
  32. package/live/approval-lifecycle.d.ts +30 -0
  33. package/live/approval-lifecycle.js +80 -0
  34. package/live/bracket-types.d.ts +9 -0
  35. package/live/live-adapter.d.ts +0 -1
  36. package/live/proposal-decision-listener.d.ts +20 -0
  37. package/live/proposal-decision-listener.js +211 -48
  38. package/onboarding/runtime.d.ts +34 -1
  39. package/onboarding/runtime.js +56 -5
  40. package/openclaw.plugin.json +1 -1
  41. package/package.json +2 -2
  42. package/simulator/exchange-simulator.d.ts +45 -2
  43. package/simulator/exchange-simulator.js +96 -4
  44. package/simulator/types.d.ts +17 -0
  45. package/tools/attach-brackets.js +50 -1
  46. package/tools/create-order.d.ts +11 -0
  47. package/tools/create-order.js +23 -2
  48. package/tools/get-risk-summary.d.ts +4 -0
  49. package/tools/get-risk-summary.js +62 -23
  50. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
  51. package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
  52. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  53. package/venues/hyperliquid/hl-brackets.js +45 -13
  54. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  55. package/venues/hyperliquid/hl-fill-ingest.js +69 -0
  56. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  57. package/venues/hyperliquid/hl-live-adapter.js +155 -12
  58. package/venues/hyperliquid/hl-order.d.ts +35 -0
  59. package/venues/hyperliquid/hl-order.js +123 -0
  60. package/venues/hyperliquid/hl-position.d.ts +36 -0
  61. package/venues/hyperliquid/hl-position.js +127 -0
  62. package/venues/hyperliquid/hl-private.d.ts +20 -3
  63. package/venues/hyperliquid/hl-private.js +37 -6
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
@@ -3,29 +3,31 @@
3
3
  //
4
4
  // Phase B implementation: polls GET /api/internal/proposed_orders/pending-decisions
5
5
  // at config.pollIntervalMs (default 3 sec). For each approved-but-unfired row:
6
- // 1. Drift abandonif mark price has moved more than ±0.3 R from the
6
+ // 1. POST /claimatomically acquire non-transferable DB ownership before
7
+ // any exchange side effect. A second listener cannot acquire the row.
8
+ // 2. Drift abandon — if mark price has moved more than ±0.3 R from the
7
9
  // proposed entry since the proposal was created, fail with a transparent
8
10
  // `fireError` rather than firing into a different setup.
9
- // 2. Fire via createOrderTool same backend function the toggle-off path
10
- // calls directly. Reuses the pre-trade risk gate, bracket attach, and
11
- // auto-capture entry-row write.
12
- // 3. PATCH /fire-result with either firedOrderId or fireError. Fired-once
13
- // idempotency lives in the DB constraint (status='approved' AND
14
- // fired_order_id IS NULL filter).
11
+ // 3. Fire via createOrderTool with a proposal-derived exchange CID same
12
+ // backend function the toggle-off path calls directly. Once this boundary
13
+ // starts, only result persistence may retry.
14
+ // 4. PATCH /fire-result with the claim token and either firedOrderId or
15
+ // fireError. An exact lost-response retry is idempotently acknowledged.
15
16
  //
16
17
  // Started by index.ts only when config.approval.mode === 'per_trade' AND
17
18
  // ingest credentials are present. Drained at shutdown so an in-flight fire
18
- // completes its PATCH before the process exits.
19
+ // gets one final opportunity to complete its PATCH before the process exits.
19
20
  //
20
21
  // Why polling and not relay events: simpler — no skill <-> plugin event-bus
21
22
  // changes needed. 3-sec poll is well under the latency a human approval flow
22
23
  // tolerates. Refactor to SSE/relay-push only if measurement shows the poll
23
- // load is meaningful (very unlikely — ~3 req/s per user is negligible).
24
+ // load is meaningful (very unlikely — ~0.33 req/s per active user is small).
24
25
  //
25
26
  // See docs/APPROVAL_MODE_DESIGN.md §2 (architecture) + §7.3 (re-validation) +
26
27
  // §10 failure matrix.
28
+ import { randomUUID } from 'node:crypto';
27
29
  import { logger, formatError } from '../logger.js';
28
- import { createOrderTool } from '../tools/create-order.js';
30
+ import { createOrderTool, proposalEntryClientOrderId, } from '../tools/create-order.js';
29
31
  const TAG = 'proposal-decision-listener';
30
32
  export class ProposalDecisionListener {
31
33
  opts;
@@ -40,6 +42,9 @@ export class ProposalDecisionListener {
40
42
  running: false,
41
43
  ticksTotal: 0,
42
44
  ticksWithFires: 0,
45
+ claimsAcquired: 0,
46
+ claimConflicts: 0,
47
+ claimFailures: 0,
43
48
  firesAttempted: 0,
44
49
  firesSucceeded: 0,
45
50
  firesAbandonedDrift: 0,
@@ -48,6 +53,21 @@ export class ProposalDecisionListener {
48
53
  pollFailures: 0,
49
54
  lastTickAt: null,
50
55
  };
56
+ /** Claim tokens are process-local by design. A new process must never steal
57
+ * an old process's durable claim, because it cannot know whether the
58
+ * exchange accepted an order just before the crash. */
59
+ claimTokens = new Map();
60
+ /** In-memory work queue for claims whose POST or fire-result response was
61
+ * lost. The DB poll excludes claimed rows, so only the original process can
62
+ * retry its exact token/outcome; a restarted process cannot steal it. */
63
+ claimedCandidates = new Map();
64
+ acquiredClaims = new Set();
65
+ /** Once present, this process must never invoke createOrderTool for the
66
+ * proposal again. Any subsequent work is result persistence only. */
67
+ mutationsStarted = new Set();
68
+ /** Completed outcomes awaiting a successful fire-result response. Retrying
69
+ * this PATCH is safe; retrying the exchange mutation is not. */
70
+ pendingResults = new Map();
51
71
  constructor(options) {
52
72
  this.opts = {
53
73
  baseUrl: options.baseUrl.replace(/\/+$/, ''),
@@ -119,7 +139,14 @@ export class ProposalDecisionListener {
119
139
  async tick() {
120
140
  this.health.ticksTotal++;
121
141
  this.health.lastTickAt = new Date().toISOString();
122
- const pending = await this.fetchPending();
142
+ const fetched = await this.fetchPending();
143
+ // Claimed rows disappear from the DB poll. Keep only this process's exact
144
+ // token/candidate in a private queue so lost claim and result responses
145
+ // can be retried without making claims stealable after restart.
146
+ const pendingById = new Map(this.claimedCandidates);
147
+ for (const proposal of fetched)
148
+ pendingById.set(proposal.id, proposal);
149
+ const pending = [...pendingById.values()];
123
150
  if (pending.length === 0) {
124
151
  // Idle backoff — schedule next tick at the longer idle interval until
125
152
  // the queue becomes non-empty. This is the dominant cost-reduction lever
@@ -137,12 +164,10 @@ export class ProposalDecisionListener {
137
164
  await this.firePending(p);
138
165
  }
139
166
  catch (err) {
140
- // Defensive firePending should already write a fireError PATCH on
141
- // any failure path. If something escapes, surface it loudly so the
142
- // operator can diagnose; the proposal will remain in approved state
143
- // and be retried on the next tick (idempotent because firedOrderId
144
- // is still NULL until a successful PATCH writes it).
145
- logger.error(TAG, `Unhandled exception firing proposal ${p.id} (will retry next tick): ${formatError(err)}`);
167
+ // Before the mutation latch, the original token/candidate may retry.
168
+ // After the latch, firePending refuses another exchange call and the
169
+ // durable claim/CID require manual reconciliation if no result exists.
170
+ logger.error(TAG, `Unhandled exception processing claimed proposal ${p.id}: ${formatError(err)}`);
146
171
  }
147
172
  }
148
173
  }
@@ -158,6 +183,7 @@ export class ProposalDecisionListener {
158
183
  headers: {
159
184
  authorization: `Bearer ${this.opts.ingestToken}`,
160
185
  'x-user-id': this.opts.userId,
186
+ 'x-reefclaw-proposal-claim-version': '1',
161
187
  },
162
188
  signal: ac.signal,
163
189
  });
@@ -179,8 +205,97 @@ export class ProposalDecisionListener {
179
205
  return [];
180
206
  }
181
207
  }
208
+ async claimPending(proposalId, claimToken) {
209
+ const url = `${this.opts.baseUrl}/api/internal/proposed_orders/${proposalId}/claim`;
210
+ try {
211
+ const ac = new AbortController();
212
+ const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
213
+ let res;
214
+ try {
215
+ res = await this.opts.fetchImpl(url, {
216
+ method: 'POST',
217
+ headers: {
218
+ 'content-type': 'application/json',
219
+ authorization: `Bearer ${this.opts.ingestToken}`,
220
+ 'x-user-id': this.opts.userId,
221
+ },
222
+ body: JSON.stringify({ claimToken }),
223
+ signal: ac.signal,
224
+ });
225
+ }
226
+ finally {
227
+ clearTimeout(tid);
228
+ }
229
+ if (res.status === 409) {
230
+ this.health.claimConflicts++;
231
+ logger.info(TAG, `Proposal ${proposalId} was claimed by another listener`);
232
+ return 'conflict';
233
+ }
234
+ if (!res.ok) {
235
+ this.health.claimFailures++;
236
+ logger.warn(TAG, `POST ${url} returned ${res.status}`);
237
+ return 'unknown';
238
+ }
239
+ const body = await res.json();
240
+ if (body.ok !== true
241
+ || body.id !== proposalId
242
+ || body.claimToken !== claimToken) {
243
+ this.health.claimFailures++;
244
+ logger.warn(TAG, `POST ${url} returned a malformed claim acknowledgement`);
245
+ return 'unknown';
246
+ }
247
+ return 'acquired';
248
+ }
249
+ catch (err) {
250
+ this.health.claimFailures++;
251
+ logger.warn(TAG, `POST ${url} threw: ${formatError(err)}`);
252
+ return 'unknown';
253
+ }
254
+ }
255
+ forgetClaim(proposalId) {
256
+ this.claimTokens.delete(proposalId);
257
+ this.claimedCandidates.delete(proposalId);
258
+ this.acquiredClaims.delete(proposalId);
259
+ this.mutationsStarted.delete(proposalId);
260
+ this.pendingResults.delete(proposalId);
261
+ }
182
262
  async firePending(p) {
263
+ let claimToken = this.claimTokens.get(p.id);
264
+ if (!claimToken) {
265
+ claimToken = randomUUID();
266
+ this.claimTokens.set(p.id, claimToken);
267
+ this.claimedCandidates.set(p.id, p);
268
+ }
269
+ if (!this.acquiredClaims.has(p.id)) {
270
+ const claimStatus = await this.claimPending(p.id, claimToken);
271
+ if (claimStatus !== 'acquired') {
272
+ if (claimStatus === 'conflict')
273
+ this.forgetClaim(p.id);
274
+ return;
275
+ }
276
+ this.acquiredClaims.add(p.id);
277
+ this.health.claimsAcquired++;
278
+ }
279
+ const awaitingPersistence = this.pendingResults.get(p.id);
280
+ if (awaitingPersistence) {
281
+ if (await this.patchResult(p.id, awaitingPersistence)
282
+ && awaitingPersistence.firedOrderId) {
283
+ this.health.firesSucceeded++;
284
+ }
285
+ return;
286
+ }
287
+ if (this.mutationsStarted.has(p.id)) {
288
+ logger.error(TAG, `Proposal ${p.id} has an ambiguous claimed mutation; refusing automatic re-fire`);
289
+ return;
290
+ }
183
291
  this.health.firesAttempted++;
292
+ if (Date.now() >= new Date(p.hardExpiresAt).getTime()) {
293
+ await this.patchResult(p.id, {
294
+ fireError: `hard_expired before fire (expired ${p.hardExpiresAt})`,
295
+ });
296
+ this.health.firesFailedTrading++;
297
+ return;
298
+ }
184
299
  // ---- 1. Drift abandon (Locked Decision #6) ----
185
300
  // Skip if we can't read a current price — better to let the trading path
186
301
  // surface the error than to silently abandon on a transient ticker miss.
@@ -198,35 +313,60 @@ export class ProposalDecisionListener {
198
313
  }
199
314
  }
200
315
  }
316
+ // Validation can cross the hard-expiry boundary. Claim ownership alone is
317
+ // not authorization to submit after the deadline.
318
+ if (Date.now() >= new Date(p.hardExpiresAt).getTime()) {
319
+ await this.patchResult(p.id, {
320
+ fireError: `hard_expired before exchange mutation (expired ${p.hardExpiresAt})`,
321
+ });
322
+ this.health.firesFailedTrading++;
323
+ return;
324
+ }
201
325
  // ---- 2. Fire via createOrderTool (same path toggle-off mode uses) ----
202
326
  // CRITICAL: do NOT pass proposalManager + userId here. The listener IS the
203
327
  // consumer of approved proposals; passing proposalManager would recursively
204
328
  // dual-write another proposal row for the same intent. The agent's path
205
329
  // dual-writes; the listener's path fires the underlying order.
206
- const result = await createOrderTool({
207
- symbol: p.symbol,
208
- side: p.side,
209
- type: p.orderType,
210
- amount: p.size,
211
- // For limit orders we honour the original proposed entry. For market,
212
- // the create_order tool reads the live ticker (per existing semantics).
213
- price: p.orderType === 'limit' ? p.proposedEntry : undefined,
214
- stopPrice: p.stopPrice,
215
- target_price: p.targetPrice,
216
- setup_type: p.setupType,
217
- thesis: p.thesis,
218
- regime: p.regime,
219
- regime_confidence: p.regimeConfidence,
220
- scorecard_verdict: p.scorecardVerdict,
221
- confluence_score: p.confluenceScore,
222
- }, {
223
- binanceApi: this.opts.binanceApi,
224
- adapter: this.opts.adapter,
225
- autoCapture: this.opts.autoCapture,
226
- operationLock: this.opts.operationLock,
227
- checkWave9LiveSymbolOwnership: this.opts.checkWave9LiveSymbolOwnership,
228
- // proposalManager + userId deliberately omitted — see above.
229
- });
330
+ const entryClientOrderId = proposalEntryClientOrderId(this.opts.adapter, p.proposalUuid);
331
+ // This latch is set immediately before the function that can touch the
332
+ // exchange. No path clears it until fire-result is durably acknowledged.
333
+ this.mutationsStarted.add(p.id);
334
+ let result;
335
+ try {
336
+ result = await createOrderTool({
337
+ symbol: p.symbol,
338
+ side: p.side,
339
+ type: p.orderType,
340
+ amount: p.size,
341
+ // For limit orders we honour the original proposed entry. For market,
342
+ // the create_order tool reads the live ticker (per existing semantics).
343
+ price: p.orderType === 'limit' ? p.proposedEntry : undefined,
344
+ stopPrice: p.stopPrice,
345
+ target_price: p.targetPrice,
346
+ setup_type: p.setupType,
347
+ thesis: p.thesis,
348
+ regime: p.regime,
349
+ regime_confidence: p.regimeConfidence,
350
+ scorecard_verdict: p.scorecardVerdict,
351
+ confluence_score: p.confluenceScore,
352
+ }, {
353
+ binanceApi: this.opts.binanceApi,
354
+ adapter: this.opts.adapter,
355
+ autoCapture: this.opts.autoCapture,
356
+ operationLock: this.opts.operationLock,
357
+ checkWave9LiveSymbolOwnership: this.opts.checkWave9LiveSymbolOwnership,
358
+ entryClientOrderId,
359
+ // proposalManager + userId deliberately omitted — see above.
360
+ });
361
+ }
362
+ catch (err) {
363
+ await this.patchResult(p.id, {
364
+ fireError: `order_outcome_ambiguous: create_order threw after mutation boundary ` +
365
+ `(clientOrderId=${entryClientOrderId}): ${formatError(err)}`,
366
+ });
367
+ this.health.firesFailedTrading++;
368
+ return;
369
+ }
230
370
  if ('error' in result) {
231
371
  // Trading-path rejection (risk gate, bracket attach failure, exchange
232
372
  // rejection, etc.). Surface to the operator via the proposal row.
@@ -246,12 +386,30 @@ export class ProposalDecisionListener {
246
386
  this.health.firesFailedTrading++;
247
387
  return;
248
388
  }
249
- const firedOrderId = typeof result.id === 'string' ? result.id : `unknown-${Date.now()}`;
389
+ if (typeof result.id !== 'string' || result.id.length === 0) {
390
+ await this.patchResult(p.id, {
391
+ fireError: `order_outcome_ambiguous: exchange acknowledgement had no order id ` +
392
+ `(clientOrderId=${entryClientOrderId})`,
393
+ });
394
+ this.health.firesFailedTrading++;
395
+ return;
396
+ }
397
+ const firedOrderId = result.id;
250
398
  logger.info(TAG, `Proposal ${p.id} fired → order ${firedOrderId}`);
251
- await this.patchResult(p.id, { firedOrderId });
252
- this.health.firesSucceeded++;
399
+ if (await this.patchResult(p.id, { firedOrderId })) {
400
+ this.health.firesSucceeded++;
401
+ }
253
402
  }
254
403
  async patchResult(proposalId, body) {
404
+ const claimToken = this.claimTokens.get(proposalId);
405
+ if (!claimToken) {
406
+ this.health.patchFailures++;
407
+ logger.error(TAG, `Refusing fire-result for unclaimed proposal ${proposalId}`);
408
+ return;
409
+ }
410
+ // Persist locally before the HTTP attempt. Any lost/failed response retries
411
+ // only this PATCH; createOrderTool is never invoked again.
412
+ this.pendingResults.set(proposalId, body);
255
413
  const url = `${this.opts.baseUrl}/api/internal/proposed_orders/${proposalId}/fire-result`;
256
414
  try {
257
415
  const ac = new AbortController();
@@ -265,7 +423,7 @@ export class ProposalDecisionListener {
265
423
  authorization: `Bearer ${this.opts.ingestToken}`,
266
424
  'x-user-id': this.opts.userId,
267
425
  },
268
- body: JSON.stringify(body),
426
+ body: JSON.stringify({ claimToken, ...body }),
269
427
  signal: ac.signal,
270
428
  });
271
429
  }
@@ -273,9 +431,12 @@ export class ProposalDecisionListener {
273
431
  clearTimeout(tid);
274
432
  }
275
433
  if (res.status === 409) {
276
- // Already-recorded race — listener double-fired but webapp's idempotency
277
- // guard caught the duplicate. Not an error.
278
- logger.info(TAG, `Proposal ${proposalId} fire-result already recorded (409, expected on retry)`);
434
+ this.health.patchFailures++;
435
+ logger.error(TAG, `Proposal ${proposalId} fire-result claim mismatch (409)`);
436
+ // Never retry the exchange mutation. Exact lost-response retries are
437
+ // 200; a 409 means this process cannot commit the result and the
438
+ // durable claim must be reconciled manually.
439
+ this.forgetClaim(proposalId);
279
440
  return;
280
441
  }
281
442
  if (!res.ok) {
@@ -283,6 +444,8 @@ export class ProposalDecisionListener {
283
444
  logger.warn(TAG, `PATCH ${url} → ${res.status}`);
284
445
  return;
285
446
  }
447
+ this.forgetClaim(proposalId);
448
+ return true;
286
449
  }
287
450
  catch (err) {
288
451
  this.health.patchFailures++;
@@ -3,14 +3,31 @@ import type { ExchangeConfig, TradingMode } from '../types.js';
3
3
  import type { ExchangeSimulator } from '../simulator/exchange-simulator.js';
4
4
  import type { PaperMarketFeed } from '../simulator/paper-market-feed.js';
5
5
  import { LiveAdapter } from '../live/live-adapter.js';
6
+ import { HyperliquidLiveAdapter } from '../venues/hyperliquid/hl-live-adapter.js';
6
7
  import type { HlCredentials } from '../venues/hyperliquid/hl-private.js';
7
8
  import { type VenueId } from '../venues/registry.js';
8
9
  import { PositionWatcher } from '../live/stop-watcher.js';
10
+ import type { TradeStoreClient } from '../ingest/trade-store-client.js';
11
+ import type { AutoCaptureContext } from '../ingest/position-auto-capture.js';
9
12
  import type { TradingOperationLock } from '../lifecycle/trading-operation-lock.js';
10
13
  export interface MicroLiveConfig {
11
- sizeCapPercent?: number;
12
14
  maxPositionUSDT?: number;
13
15
  }
16
+ /** Boot-constructed live wiring reapplied to EVERY adapter build (audit
17
+ * 2026-07-26 F9). Reconnects previously rebuilt adapters with only the
18
+ * bracket mode — silently reverting WS authority to REST, dropping the
19
+ * audit-trail ingest, and losing journal auto-capture until restart. The
20
+ * clients live for the process lifetime (the SIGTERM drain holds them), so
21
+ * rebuilds must REUSE them, never re-instantiate. */
22
+ export interface LiveAdapterWiring {
23
+ /** Base of TradeIngestWiring — `exchange` is stamped per build from the
24
+ * TARGET venue (fillExchangeId), because a venue flip changes it. */
25
+ tradeIngestBase?: {
26
+ client: TradeStoreClient;
27
+ userId: string;
28
+ };
29
+ autoCapture?: AutoCaptureContext;
30
+ }
14
31
  export interface BuildAdapterInput {
15
32
  mode: TradingMode;
16
33
  exchange: ExchangeConfig | null;
@@ -24,6 +41,8 @@ export interface BuildAdapterInput {
24
41
  venue?: VenueId;
25
42
  /** Required for a live-mode build on the hyperliquid venue. */
26
43
  hlCredentials?: HlCredentials | null;
44
+ /** Boot wiring reapplied on every build — see LiveAdapterWiring (F9). */
45
+ wiring?: LiveAdapterWiring;
27
46
  }
28
47
  /** Wave 9 safety wiring is created only after its durable ledger is loaded.
29
48
  * Runtime reapplies these hooks to both the bootstrap objects and every
@@ -62,6 +81,17 @@ export declare class PluginRuntime {
62
81
  * for watcher closes (issue #199) — without it, a live<->paper reconnect
63
82
  * would silently shed the capture wiring. */
64
83
  private readonly onWatcherCreated?;
84
+ /** Observer applied to EVERY live adapter this runtime creates (audit F9,
85
+ * same pattern as onWatcherCreated). index.ts uses it to install the
86
+ * drift_detected → journal close-bypass cleanup listener — previously
87
+ * installed only on the BOOT adapter and lost on every reconnect. */
88
+ private readonly onAdapterCreated?;
89
+ /** Boot live wiring threaded into every buildAdapter call (audit F9). */
90
+ private readonly liveWiring?;
91
+ /** Fired after EVERY reconnect publishes its adapter (paper ones included) —
92
+ * set post-construction because its consumer (the approval-listener
93
+ * lifecycle, audit F10) is built after the runtime. */
94
+ private onAdapterSwapped?;
65
95
  /** Reconnect is serialized — a second caller waits for the first to finish
66
96
  * so we never tear down an adapter that's mid-rebuild. */
67
97
  private reconnectInFlight;
@@ -73,11 +103,14 @@ export declare class PluginRuntime {
73
103
  marketFeed?: PaperMarketFeed | null;
74
104
  operationLock?: TradingOperationLock;
75
105
  onWatcherCreated?: (watcher: PositionWatcher) => void;
106
+ onAdapterCreated?: (adapter: LiveAdapter | HyperliquidLiveAdapter) => void;
107
+ liveWiring?: LiveAdapterWiring;
76
108
  });
77
109
  get adapter(): IExchangeAdapter;
78
110
  get mode(): TradingMode;
79
111
  get stopWatcher(): PositionWatcher | null;
80
112
  get marketFeed(): PaperMarketFeed | null;
113
+ setOnAdapterSwapped(cb?: (adapter: IExchangeAdapter) => void): void;
81
114
  setWave9LiveLifecycleHooks(hooks?: Wave9LiveLifecycleHooks): void;
82
115
  /**
83
116
  * Swap the current adapter for a new one built from `next`. The old
@@ -14,10 +14,11 @@
14
14
  import { PaperAdapter } from '../paper-adapter.js';
15
15
  import { LiveAdapter } from '../live/live-adapter.js';
16
16
  import { HyperliquidLiveAdapter } from '../venues/hyperliquid/hl-live-adapter.js';
17
- import { createLiveAdapter } from '../venues/registry.js';
17
+ import { createLiveAdapter, fillExchangeId } from '../venues/registry.js';
18
18
  import { PositionWatcher } from '../live/stop-watcher.js';
19
19
  import { loadBracketMode } from '../config/brackets-config.js';
20
- import { loadStopWatcherIntervalMs, readPluginConfig } from '../config/plugin-config-io.js';
20
+ import { loadMicroLiveConfig, loadStopWatcherIntervalMs, readPluginConfig } from '../config/plugin-config-io.js';
21
+ import { loadUserDataStreamMode, loadUserDataStreamTunables } from '../config/user-data-stream-config.js';
21
22
  import { logger, formatError } from '../logger.js';
22
23
  const TAG = 'plugin-runtime';
23
24
  /** Pure-ish factory: builds an adapter for the requested mode.
@@ -25,12 +26,27 @@ const TAG = 'plugin-runtime';
25
26
  * callers should pre-validate via `modeRequiresCredentials`, but this
26
27
  * defense-in-depth prevents a crash if validation is bypassed. */
27
28
  export function buildAdapter(input) {
28
- const { mode, exchange, microLive, simulator } = input;
29
+ const { mode, exchange, simulator } = input;
29
30
  if (mode === 'PAPER' || mode === 'SHADOW') {
30
31
  // Shadow mode uses a paper adapter for execution; the separate
31
32
  // ShadowTracker wraps a BinancePrivateApi for real-balance comparison.
32
33
  return new PaperAdapter(simulator);
33
34
  }
35
+ // Micro-live cap resolves from plugin-config at adapter build time when the
36
+ // caller didn't pass one — every reconnect path (mode flip, credential save)
37
+ // used to omit it, silently resetting an operator-raised OR -lowered cap
38
+ // back to the $50 default (audit 2026-07-26 F8).
39
+ const microLive = input.microLive ?? loadMicroLiveConfig();
40
+ // Audit-trail ingest (F9): reuse the boot-constructed client, stamp the
41
+ // exchange id from the TARGET venue (a venue flip changes it; the id is
42
+ // half of the trades idempotency key and must never be stale or minted).
43
+ const tradeIngest = input.wiring?.tradeIngestBase
44
+ ? {
45
+ client: input.wiring.tradeIngestBase.client,
46
+ userId: input.wiring.tradeIngestBase.userId,
47
+ exchange: fillExchangeId(input.venue ?? 'binance'),
48
+ }
49
+ : undefined;
34
50
  // Hyperliquid live (issue #217): mirror the boot path's construction —
35
51
  // per-venue credential shape, the SAME factory (createLiveAdapter), and
36
52
  // the same fall-back-to-paper defense when credentials are absent.
@@ -45,6 +61,8 @@ export function buildAdapter(input) {
45
61
  credentials: input.hlCredentials,
46
62
  mode: mode,
47
63
  marketSlippagePct: readPluginConfig().hl?.marketSlippagePct,
64
+ microLive,
65
+ tradeIngest,
48
66
  },
49
67
  });
50
68
  }
@@ -55,7 +73,12 @@ export function buildAdapter(input) {
55
73
  // Read bracket-mode from plugin-config at adapter build time so a config
56
74
  // flip + "Reconnect Exchange" pattern picks up the new mode on the next swap.
57
75
  const bracketMode = loadBracketMode();
58
- return new LiveAdapter(exchange, mode, microLive, bracketMode);
76
+ // Same read-at-build-time rule for the user-data stream (F9): a rebuilt
77
+ // adapter wires its WS at construction, so passing nothing here reverted
78
+ // prod's `enforce` to REST-only polling on every dashboard reconnect.
79
+ const userDataStreamMode = loadUserDataStreamMode();
80
+ const userDataStreamTunables = loadUserDataStreamTunables();
81
+ return new LiveAdapter(exchange, mode, microLive, bracketMode, userDataStreamMode, userDataStreamTunables, tradeIngest, input.wiring?.autoCapture);
59
82
  }
60
83
  /**
61
84
  * Mutable runtime holder. Held once per plugin registration.
@@ -82,6 +105,17 @@ export class PluginRuntime {
82
105
  * for watcher closes (issue #199) — without it, a live<->paper reconnect
83
106
  * would silently shed the capture wiring. */
84
107
  onWatcherCreated;
108
+ /** Observer applied to EVERY live adapter this runtime creates (audit F9,
109
+ * same pattern as onWatcherCreated). index.ts uses it to install the
110
+ * drift_detected → journal close-bypass cleanup listener — previously
111
+ * installed only on the BOOT adapter and lost on every reconnect. */
112
+ onAdapterCreated;
113
+ /** Boot live wiring threaded into every buildAdapter call (audit F9). */
114
+ liveWiring;
115
+ /** Fired after EVERY reconnect publishes its adapter (paper ones included) —
116
+ * set post-construction because its consumer (the approval-listener
117
+ * lifecycle, audit F10) is built after the runtime. */
118
+ onAdapterSwapped;
85
119
  /** Reconnect is serialized — a second caller waits for the first to finish
86
120
  * so we never tear down an adapter that's mid-rebuild. */
87
121
  reconnectInFlight = null;
@@ -93,11 +127,16 @@ export class PluginRuntime {
93
127
  this._marketFeed = initial.marketFeed ?? null;
94
128
  this.operationLock = initial.operationLock;
95
129
  this.onWatcherCreated = initial.onWatcherCreated;
130
+ this.onAdapterCreated = initial.onAdapterCreated;
131
+ this.liveWiring = initial.liveWiring;
96
132
  }
97
133
  get adapter() { return this._adapter; }
98
134
  get mode() { return this._mode; }
99
135
  get stopWatcher() { return this._stopWatcher; }
100
136
  get marketFeed() { return this._marketFeed; }
137
+ setOnAdapterSwapped(cb) {
138
+ this.onAdapterSwapped = cb;
139
+ }
101
140
  setWave9LiveLifecycleHooks(hooks) {
102
141
  this.wave9LiveLifecycleHooks = hooks;
103
142
  if (this._adapter instanceof LiveAdapter) {
@@ -160,7 +199,9 @@ export class PluginRuntime {
160
199
  logger.warn(TAG, `old HL adapter stop failed: ${formatError(err)}`);
161
200
  }
162
201
  }
163
- // 3. Build the new adapter.
202
+ // 3. Build the new adapter — with the boot live wiring, so a reconnect
203
+ // can never silently shed WS authority / audit ingest / auto-capture
204
+ // (audit F9).
164
205
  const fresh = buildAdapter({
165
206
  mode: next.mode,
166
207
  exchange: next.exchange,
@@ -168,12 +209,18 @@ export class PluginRuntime {
168
209
  simulator: this.simulator,
169
210
  venue: next.venue,
170
211
  hlCredentials: next.hlCredentials,
212
+ wiring: this.liveWiring,
171
213
  });
172
214
  // Install autonomous protection callbacks before initialization can emit
173
215
  // user-data or bracket-reconciler events.
174
216
  if (fresh instanceof LiveAdapter) {
175
217
  this.wave9LiveLifecycleHooks?.configureLiveAdapter?.(fresh);
176
218
  }
219
+ // Same before-init rule for the drift_detected journal cleanup (F9): the
220
+ // reconciler can emit on its first poll.
221
+ if (fresh instanceof LiveAdapter || fresh instanceof HyperliquidLiveAdapter) {
222
+ this.onAdapterCreated?.(fresh);
223
+ }
177
224
  // 4. Fire async init for live adapters (non-blocking — readiness flips
178
225
  // INIT_PENDING → READY/DEGRADED/BLOCKED on its own).
179
226
  if (fresh instanceof LiveAdapter || fresh instanceof HyperliquidLiveAdapter) {
@@ -210,6 +257,10 @@ export class PluginRuntime {
210
257
  this._marketFeed.start();
211
258
  }
212
259
  }
260
+ // 8. Notify swap observers (approval-listener lifecycle etc. — F10).
261
+ // Fired for EVERY swap including paper, so a live→PAPER flip can tear
262
+ // down consumers bound to the orphaned live adapter.
263
+ this.onAdapterSwapped?.(fresh);
213
264
  logger.info(TAG, `Reconnect complete: now in ${this._mode} mode (readiness=${fresh.readiness})`);
214
265
  }
215
266
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.12",
4
+ "version": "0.1.14",
5
5
  "description": "Supervised trading plugin for the ReefClaw dashboard: paper trading with real market data (no API keys required), and optional live trading on Binance or Hyperliquid behind explicit operator opt-in, exchange API credentials, and always-on protective stop brackets. Includes the dashboard connector bridge, heartbeat automation, and remote SKILL.md instruction updates from the ReefClaw webapp.",
6
6
  "author": "ReefClaw",
7
7
  "activation": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "ReefClaw supervised trading plugin for OpenClaw \u2014 paper trading with real market data, optional live trading on Binance or Hyperliquid (operator opt-in, API keys, always-on protective brackets), plus the ReefClaw dashboard connector with heartbeat automation and remote SKILL.md updates from the ReefClaw webapp. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -22,7 +22,7 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "dependencies": {
25
- "@reefclaw/shared": "0.1.2",
25
+ "@reefclaw/shared": "0.1.3",
26
26
  "ccxt": "4.5.37",
27
27
  "json5": "2.2.3",
28
28
  "ws": "8.19.0"