@reefclaw/connect 0.1.31 → 0.1.32

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.
@@ -22,7 +22,9 @@ export declare class Connector {
22
22
  private lastMessageAt;
23
23
  private destroyed;
24
24
  constructor(config: ConnectorConfig, callbacks: ConnectorCallbacks);
25
- /** Build the relay WebSocket URL (token is sent via Authorization header, not URL) */
25
+ /** Build the relay WebSocket URL (token is sent via Authorization header, not
26
+ * URL; the instance id is non-secret and rides the query string so the
27
+ * relay's skill-slot admission can recognise a same-box restart). */
26
28
  private buildUrl;
27
29
  /** Start connecting to the relay */
28
30
  connect(): void;
@@ -37,10 +37,15 @@ export class Connector {
37
37
  this.reconnectConfig = { ...DEFAULT_RECONNECT, ...config.reconnect };
38
38
  this.heartbeatConfig = { ...DEFAULT_HEARTBEAT };
39
39
  }
40
- /** Build the relay WebSocket URL (token is sent via Authorization header, not URL) */
40
+ /** Build the relay WebSocket URL (token is sent via Authorization header, not
41
+ * URL; the instance id is non-secret and rides the query string so the
42
+ * relay's skill-slot admission can recognise a same-box restart). */
41
43
  buildUrl() {
42
44
  const base = this.config.relayUrl.replace(/\/$/, '');
43
- return `${base}/parties/reefclaw/${this.config.userId}`;
45
+ const path = `${base}/parties/reefclaw/${this.config.userId}`;
46
+ return this.config.instanceId
47
+ ? `${path}?instance=${encodeURIComponent(this.config.instanceId)}`
48
+ : path;
44
49
  }
45
50
  /** Start connecting to the relay */
46
51
  connect() {
@@ -96,6 +101,24 @@ export class Connector {
96
101
  }, 15 * 60_000);
97
102
  return;
98
103
  }
104
+ // 4011 = another agent holds this account's skill slot and is alive
105
+ // (relay skill-slot admission, 2026-08-25 — the fix for the 4010
106
+ // ping-pong two bridges used to fight). Retrying fast is pointless and
107
+ // noisy: the seated agent keeps the slot until it stops. Slow-probe
108
+ // every 5 min so a deliberate box switch (stop the old one) is picked
109
+ // up without a manual restart here.
110
+ if (code === 4011) {
111
+ logger.error(TAG, `Relay refused this connection (4011): another agent is already connected for this ` +
112
+ `account. ReefClaw runs ONE agent per account — stop the other agent (or revoke its ` +
113
+ `token in the dashboard) to move this box in. Probing again in 5 min.`);
114
+ this.setState('failed');
115
+ this.attempt = 0;
116
+ this.reconnectTimer = setTimeout(() => {
117
+ this.reconnectTimer = null;
118
+ this.connect();
119
+ }, 5 * 60_000);
120
+ return;
121
+ }
99
122
  // 4002 = stale skill connection on relay. Wait for it to time out, then retry.
100
123
  if (code === 4002) {
101
124
  logger.warn(TAG, 'Stale skill connection on relay — waiting 10s before retry');
@@ -275,6 +298,18 @@ export class Connector {
275
298
  // Send a native WebSocket ping (protocol-level, handled by PartyKit automatically).
276
299
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
277
300
  this.ws.ping();
301
+ // App-level keepalive: native pings are absorbed by the PartyKit
302
+ // runtime and never reach the relay DO, so an idle-but-healthy agent
303
+ // would look dead to the skill-slot admission's liveness window and
304
+ // could be evicted by a second box's probe. One tiny frame per
305
+ // heartbeat tick keeps the seat provably occupied; the relay swallows
306
+ // it (never forwarded to browsers, never audited).
307
+ this.ws.send(JSON.stringify({
308
+ type: 'event',
309
+ event: 'skill_keepalive',
310
+ channel: 'agent_state',
311
+ payload: { ts: Date.now() },
312
+ }));
278
313
  }
279
314
  }, this.heartbeatConfig.intervalMs);
280
315
  logger.debug(TAG, `Heartbeat started: interval=${this.heartbeatConfig.intervalMs}ms timeout=${this.heartbeatConfig.timeoutMs}ms`);
@@ -49,7 +49,8 @@ export const HEARTBEAT_MESSAGE = 'Heartbeat. Execute EVERY checkbox in the HEART
49
49
  'do NOT re-read SKILL.md from disk unless preparing a NEW entry). ' +
50
50
  'NON-SKIPPABLE every beat: (1) query_trades({hours:1}) stop-watcher reconcile; ' +
51
51
  '(2) get_wave9_status() once, unconditionally; ' +
52
- '(3) if ANY position is open: get_my_recent_reviews() + get_relevant_learnings({applies_at: heartbeat}) in one batch, ' +
52
+ '(3) if ANY position is open and the Position Decision Journal is enabled (record_position_reviews reports off-mode when it is not): ' +
53
+ 'get_my_recent_reviews() + get_relevant_learnings({applies_at: heartbeat}) in one batch, ' +
53
54
  'then record_position_reviews with ONE review per open position, ' +
54
55
  'plus get_resting_liquidity + get_liquidation_levels + get_liquidation_pulse for ALL positions in one parallel batch; ' +
55
56
  '(4) the Market Assessment reads. ' +
@@ -17,6 +17,7 @@ import { GatewayProvider } from './providers/gateway.js';
17
17
  import { resolveConfig } from './config.js';
18
18
  import { resolveGatewayConfig, validateGatewayConfig } from './gateway/gateway-config.js';
19
19
  import { runSetup } from './setup.js';
20
+ import { resolveRelayInstanceId } from './utils/instance-id.js';
20
21
  const TAG = 'main';
21
22
  // ---- Load .env file (skill/.env only) ----
22
23
  function loadEnvFile() {
@@ -223,6 +224,9 @@ async function main() {
223
224
  relayUrl,
224
225
  userId,
225
226
  token,
227
+ // Stable per-install id → the relay's skill-slot admission recognises a
228
+ // same-box restart (instant takeover) vs a second box (rejected 4011).
229
+ instanceId: resolveRelayInstanceId(),
226
230
  };
227
231
  const bridge = new Bridge(provider, connectorConfig);
228
232
  // Handle graceful shutdown
@@ -602,6 +602,10 @@ export interface ConnectorConfig {
602
602
  relayUrl: string;
603
603
  userId: string;
604
604
  token: string;
605
+ /** Stable per-install id (non-secret), sent as `?instance=` so the relay's
606
+ * skill-slot admission can tell "same box restarting" (instant takeover)
607
+ * from "second box" (rejected 4011). See utils/instance-id.ts. */
608
+ instanceId?: string;
605
609
  reconnect?: {
606
610
  baseDelayMs?: number;
607
611
  maxDelayMs?: number;
@@ -0,0 +1,3 @@
1
+ /** Read-or-create the stable instance id. `baseDir` overrides the storage
2
+ * directory (tests; defaults to ~/.reefclaw). Never throws. */
3
+ export declare function resolveRelayInstanceId(baseDir?: string): string;
@@ -0,0 +1,48 @@
1
+ // Stable per-install relay instance id (2026-08-25, skill-slot admission).
2
+ //
3
+ // The relay seats ONE skill connection per account and refuses newcomers —
4
+ // EXCEPT a newcomer proving it is the same box restarting, which takes over
5
+ // instantly (the graceful-restart property). "Same box" = this id, sent as a
6
+ // non-secret `?instance=` query param on the relay URL. It must therefore
7
+ // survive process restarts: persisted once per install at
8
+ // `~/.reefclaw/relay-instance-id` and reused forever.
9
+ //
10
+ // Fail-open: when the filesystem refuses (read-only home, exotic container),
11
+ // fall back to a per-process id. Takeover-after-crash then degrades to the
12
+ // relay's liveness timeout instead of being instant — worse, never wrong.
13
+ import { randomUUID } from 'node:crypto';
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { logger } from '../logger.js';
18
+ const TAG = 'instance-id';
19
+ const ID_SHAPE = /^[A-Za-z0-9-]{8,64}$/;
20
+ /** Read-or-create the stable instance id. `baseDir` overrides the storage
21
+ * directory (tests; defaults to ~/.reefclaw). Never throws. */
22
+ export function resolveRelayInstanceId(baseDir) {
23
+ // Operator escape hatch (e.g. two deliberate installs sharing one home dir).
24
+ const fromEnv = process.env.RC_RELAY_INSTANCE_ID?.trim();
25
+ if (fromEnv && ID_SHAPE.test(fromEnv))
26
+ return fromEnv;
27
+ const dir = baseDir ?? join(homedir(), '.reefclaw');
28
+ const file = join(dir, 'relay-instance-id');
29
+ try {
30
+ if (existsSync(file)) {
31
+ const existing = readFileSync(file, 'utf8').trim();
32
+ if (ID_SHAPE.test(existing))
33
+ return existing;
34
+ // Garbled file: fall through and rewrite — a fresh id only costs one
35
+ // liveness-timeout takeover, a garbled param corrupts the admission key.
36
+ }
37
+ const fresh = randomUUID();
38
+ mkdirSync(dir, { recursive: true });
39
+ writeFileSync(file, fresh + '\n', 'utf8');
40
+ return fresh;
41
+ }
42
+ catch (err) {
43
+ const perProcess = randomUUID();
44
+ logger.warn(TAG, `Could not persist relay instance id (${err.message}) — using per-process id; ` +
45
+ `crash takeover degrades to the relay liveness timeout`);
46
+ return perProcess;
47
+ }
48
+ }
@@ -1418,6 +1418,11 @@ const paperTradingPlugin = {
1418
1418
  // F26: same wiring object as the Binance arm — the SIGTERM
1419
1419
  // drain covers both venues because it drains this client.
1420
1420
  tradeIngest,
1421
+ // Journal close capture (close-bypass fix, HL arm): without
1422
+ // this, every bracket SL/TP fill leaked as status='open'
1423
+ // until the reconciler healed it reason-less (50% of wisekid
1424
+ // 30d closes were reconciler_observed_flat).
1425
+ autoCapture,
1421
1426
  },
1422
1427
  }
1423
1428
  : {
@@ -19,7 +19,7 @@
19
19
  // that hooks into the WS-ingest pipeline (see POSITION_DECISION_JOURNAL_PLAN
20
20
  // §5.1 for the longer-term design).
21
21
  import { logger } from '../logger.js';
22
- import { isBracketCid } from '../live/bracket-id.js';
22
+ import { isBracketClientId, parseBracketClientId } from '../live/bracket-id.js';
23
23
  import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
24
24
  import { fillPriceFromOrder } from '../live/fill-price.js';
25
25
  import { getSkillVersionCached, withSkillVersion } from './skill-version-reader.js';
@@ -627,7 +627,13 @@ async function handleReduceOnlyExit(ctx, fill) {
627
627
  // onClosePositionFilled) or a manual/external close — both are handled by
628
628
  // their own paths (close_position's rich reason+assessment, or the reconciler
629
629
  // backstop). Closing here would clobber the agent's close reasoning, so defer.
630
- const isBracket = fill.clientOrderId ? isBracketCid(fill.clientOrderId) : false;
630
+ // Recognition is VENUE-DISPATCHED (bracket-id rule): Binance `bkt…`/`rc-…`
631
+ // cids, Hyperliquid `0xbc7…` cloids — the Binance-only check silently
632
+ // classed every HL bracket fill as external and deferred it forever.
633
+ const cidVenue = ctx.venue ?? 'binance';
634
+ const isBracket = fill.clientOrderId
635
+ ? isBracketClientId(cidVenue, fill.clientOrderId)
636
+ : false;
631
637
  if (!isBracket) {
632
638
  logger.info(TAG, `${fill.symbol} flat via non-bracket reduce-only fill (cid=${fill.clientOrderId ?? 'none'}) — ` +
633
639
  `deferring close to close_position / reconciler backstop (no clobber)`);
@@ -643,6 +649,12 @@ async function handleReduceOnlyExit(ctx, fill) {
643
649
  'Auto-journaled from the WS fill — no close_position call (close-bypass path).',
644
650
  observedFrom: 'ws_reduce_only_fill',
645
651
  clientOrderId: fill.clientOrderId,
652
+ // Which protective leg fired ('stop' | 'target'), parsed from the cid.
653
+ // Kept in the assessment (not a new close reason) so the close_reason
654
+ // vocabulary stays stable for the miner's plan-adherence classifier.
655
+ leg: fill.clientOrderId
656
+ ? parseBracketClientId(cidVenue, fill.clientOrderId)?.role
657
+ : undefined,
646
658
  },
647
659
  scorecardVerdict: 'NO_GO',
648
660
  confluenceScore: 0,
@@ -63,6 +63,10 @@ export function buildAdapter(input) {
63
63
  marketSlippagePct: readPluginConfig().hl?.marketSlippagePct,
64
64
  microLive,
65
65
  tradeIngest,
66
+ // Journal close capture (close-bypass fix) — same wiring the boot
67
+ // path passes; omitting it here would shed the capture on every
68
+ // reconnect-built adapter (the F9 class).
69
+ autoCapture: input.wiring?.autoCapture,
66
70
  },
67
71
  });
68
72
  }
@@ -76,7 +76,11 @@ export declare class ExchangeSimulator extends EventEmitter {
76
76
  * market branch prices off THIS instead of the last tick, and skips the
77
77
  * stale-quote guard — the caller supplied the price, so quote age is
78
78
  * irrelevant, and a protective exit must never be blocked (issue #202). */
79
- referencePrice?: number): CcxtOrder;
79
+ referencePrice?: number,
80
+ /** Set ONLY by closePosition for mechanical/operator-initiated exits —
81
+ * exempts them from the startup lockout. Never reachable from the
82
+ * agent-facing create_order path. */
83
+ protectiveExit?: boolean): CcxtOrder;
80
84
  cancelOrder(orderId: string): CcxtOrder;
81
85
  cancelAllOrders(symbol?: string): CcxtOrder[];
82
86
  /**
@@ -290,17 +290,29 @@ export class ExchangeSimulator extends EventEmitter {
290
290
  * market branch prices off THIS instead of the last tick, and skips the
291
291
  * stale-quote guard — the caller supplied the price, so quote age is
292
292
  * irrelevant, and a protective exit must never be blocked (issue #202). */
293
- referencePrice) {
293
+ referencePrice,
294
+ /** Set ONLY by closePosition for mechanical/operator-initiated exits —
295
+ * exempts them from the startup lockout. Never reachable from the
296
+ * agent-facing create_order path. */
297
+ protectiveExit) {
294
298
  // ---- Startup trade lockout ----
295
299
  // Block trades during the first 15s after gateway restart IF there were
296
300
  // existing positions at startup. This prevents stale agent sessions from
297
301
  // selling positions before the session is cleared and the agent re-reads SKILL.md.
298
302
  // Only activates when positions exist (nothing to protect if starting empty).
303
+ // Protective exits pass through: a stop breached 3s after a restart must
304
+ // close NOW — blocking the watcher here left positions unprotected for
305
+ // the whole window (open item since 2026-07-27).
299
306
  const elapsed = Date.now() - this.startupTime;
300
307
  if (this.hadPositionsAtStartup && elapsed < ExchangeSimulator.STARTUP_LOCKOUT_MS) {
301
- const remaining = Math.ceil((ExchangeSimulator.STARTUP_LOCKOUT_MS - elapsed) / 1000);
302
- logger.warn(TAG, `STARTUP LOCKOUT: Blocked ${side} ${amount} ${symbol} — ${remaining}s remaining. This prevents stale session trades during restart.`);
303
- throw new Error(`Trade blocked: startup lockout (${remaining}s remaining). The gateway just restarted — wait for the agent to re-read its instructions and check positions before trading.`);
308
+ if (protectiveExit) {
309
+ logger.info(TAG, `Startup lockout bypassed for protective exit: ${side} ${amount} ${symbol}`);
310
+ }
311
+ else {
312
+ const remaining = Math.ceil((ExchangeSimulator.STARTUP_LOCKOUT_MS - elapsed) / 1000);
313
+ logger.warn(TAG, `STARTUP LOCKOUT: Blocked ${side} ${amount} ${symbol} — ${remaining}s remaining. This prevents stale session trades during restart.`);
314
+ throw new Error(`Trade blocked: startup lockout (${remaining}s remaining). The gateway just restarted — wait for the agent to re-read its instructions and check positions before trading.`);
315
+ }
304
316
  }
305
317
  if (amount <= 0) {
306
318
  throw new Error('Order amount must be positive');
@@ -410,9 +422,15 @@ export class ExchangeSimulator extends EventEmitter {
410
422
  if (closeReason) {
411
423
  position.metadata = { ...(position.metadata ?? {}), closeReason };
412
424
  }
413
- // Create opposing market order to close the position
425
+ // Create opposing market order to close the position.
426
+ // Mechanical / operator-initiated exits (stop_watcher, exchange_target,
427
+ // emergency, operator, bracket_attach_failed, …) must never wait out the
428
+ // startup lockout — a stop breached seconds after a restart has to close
429
+ // immediately. Only discretionary closes ('agent' or reason-less) keep
430
+ // the stale-session guard.
431
+ const protectiveExit = closeReason !== undefined && closeReason !== 'agent';
414
432
  const closeSide = position.side === 'long' ? 'sell' : 'buy';
415
- return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice);
433
+ return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice, protectiveExit);
416
434
  }
417
435
  /** Paper-only: move an open position's MUTABLE protective levels (stopPrice /
418
436
  * targetPrice) in place and persist, WITHOUT the close+reopen round-trip
@@ -317,9 +317,18 @@ export async function closePositionTool(args, deps) {
317
317
  'Use get_wave9_status for a reversal authorization or operator_command for an emergency close.');
318
318
  }
319
319
  }
320
+ // Thread the validated reason to the adapter as its CloseReason:
321
+ // 'operator_command' maps to 'operator' so an operator-driven close is
322
+ // never caught by the paper startup lockout (whose protective-exit
323
+ // carve-out keys on the reason — before this, the tool dropped the
324
+ // reason entirely and EVERY generic close arrived as discretionary).
325
+ // All other reasons are agent-discretionary by design and stay subject
326
+ // to the lockout; the rich reason still reaches the journal via
327
+ // close_reason, this only fixes the adapter-level class + metadata.
328
+ const adapterCloseReason = args.reason === 'operator_command' ? 'operator' : 'agent';
320
329
  return wave9ExitClaimed
321
330
  ? deps.adapter.closePosition(args.symbol, 'wave9_signal_reversal')
322
- : deps.adapter.closePosition(args.symbol);
331
+ : deps.adapter.closePosition(args.symbol, adapterCloseReason);
323
332
  };
324
333
  const closeWave9 = async () => {
325
334
  if (!wave9Lease)
@@ -6,6 +6,7 @@ import { type HlCredentials } from './hl-private.js';
6
6
  import type { BracketId } from '../../live/bracket-types.js';
7
7
  import { BracketLedger } from '../../live/bracket-ledger.js';
8
8
  import { HlBracketCoordinator } from './hl-bracket-coordinator.js';
9
+ import { type AutoCaptureContext } from '../../ingest/position-auto-capture.js';
9
10
  import type { TradeIngestWiring } from '../../live/live-adapter.js';
10
11
  export interface HlLiveAdapterOptions {
11
12
  credentials: HlCredentials;
@@ -23,6 +24,16 @@ export interface HlLiveAdapterOptions {
23
24
  * via userFillsByTime. Same object boot passes the Binance adapter —
24
25
  * `exchange` MUST be fillExchangeId('hyperliquid'). */
25
26
  tradeIngest?: TradeIngestWiring;
27
+ /** Position-decision journal wiring. When present, a close-direction user-
28
+ * stream fill (dir "Close …" or a liquidation fill) is routed through
29
+ * `onWsFillObserved` so an exchange-native bracket SL/TP fill journals an
30
+ * EXACT close (reason `bracket_fill`) instead of leaking as status='open'
31
+ * until the reconciler heals it as `reconciler_observed_flat` — the HL
32
+ * analog of the wiring Binance's ws-ingest has carried since PR #205.
33
+ * Entry/scale-in fills are deliberately NOT routed (the sync create_order
34
+ * path captures them; the WS dedup key is unverified on HL — see
35
+ * onUserFill). */
36
+ autoCapture?: AutoCaptureContext;
26
37
  /** Test seams. Production omits both. */
27
38
  bracketLedger?: BracketLedger;
28
39
  disableUserStream?: boolean;
@@ -54,6 +65,10 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
54
65
  /** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
55
66
  * low-water mark the reconnect gap backfill widens from. */
56
67
  private lastFillIngestMs;
68
+ /** oid → cloid backfill for fills that omit `cloid` (the journal close path
69
+ * recognizes bracket legs by client id). Populated from `orderUpdates`,
70
+ * which always carries both. Bounded, insertion-order eviction. */
71
+ private readonly oidToCloid;
57
72
  /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
58
73
  * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
59
74
  * each balance fetch. Without this the skill self-computes a bogus anchor and
@@ -187,10 +202,20 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
187
202
  * `startPosition` is the position BEFORE this fill — the WS-authoritative
188
203
  * way to know the after-fill total without an extra REST read. */
189
204
  private onUserFill;
205
+ /** Close-direction fill → position-decision journal (close-bypass fix, HL
206
+ * arm). Fire-and-forget: a journal POST blip must never touch the WS hot
207
+ * path. Reduce-only is derived from `dir` (HL fills carry no reduceOnly
208
+ * flag): "Close Long"/"Close Short", plus the liquidation marker. Flip
209
+ * dirs ("Long > Short") are NOT closes of a tracked side we understand —
210
+ * they stay with the reconciler backstop. */
211
+ private captureCloseFill;
190
212
  /** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
191
- * analog). A trigger = the exchange closed the position — surface the same
213
+ * analog). A trigger = the exchange closed the position — surface the
192
214
  * `drift_detected` close shape the Binance reconciler emits so the journal
193
- * close-bypass cleanup fires at once, not ≤5 min late. */
215
+ * close-bypass cleanup fires fast but AFTER a short grace, so the close
216
+ * FILL (the exact-close journal path, `captureCloseFill`) wins the
217
+ * undocumented orderUpdates/userFills frame ordering. The cleanup is
218
+ * idempotent: state already dropped by the fill path ⇒ no-op. */
194
219
  private onUserOrderUpdate;
195
220
  /** T-5 REST truth-check — serialized so a slow pass can't stack. */
196
221
  private runTruthCheck;
@@ -38,7 +38,8 @@ import { generateBracketId } from '../../live/bracket-id.js';
38
38
  import { validateStopDirection, validateTargetDirection } from '../../live/bracket-params.js';
39
39
  import { HlBracketCoordinator, isRejectedOrder, isTerminalBracketState, } from './hl-bracket-coordinator.js';
40
40
  import { HyperliquidUserStream } from './hl-user-stream.js';
41
- import { hlFillToFillEvent } from './hl-fill-ingest.js';
41
+ import { hlFillToFillEvent, hlCoinToCanonical } from './hl-fill-ingest.js';
42
+ import { onWsFillObserved } from '../../ingest/position-auto-capture.js';
42
43
  import { formatError } from '../../logger.js';
43
44
  const TAG = 'hl-live-adapter';
44
45
  /** Venue-distinct ledger storage — a venue switch on the same box must never
@@ -55,6 +56,21 @@ const DEFAULT_MARKET_SLIPPAGE = 0.005;
55
56
  /** Sticky cooldown after a failed open-orders fetch (the Binance lesson: the
56
57
  * SKILL.md audit→attach loop re-calls every ~3s and would pin the budget). */
57
58
  const OPEN_ORDERS_COOLDOWN_MS = 45_000;
59
+ /** Grace before a bracket-trigger `drift_detected` emit. A trigger's close FILL
60
+ * arrives on `userFills` and journals the exact close (real price, real PnL,
61
+ * reason `bracket_fill`); the drift path's cleanup can only post a generic
62
+ * `reconciler_observed_flat`. HL's WS frame ordering between `orderUpdates`
63
+ * and `userFills` is undocumented, so without this grace the generic close
64
+ * routinely won the race and 50% of HL live closes carried no close reason
65
+ * (wisekid, 30d to 2026-08-17: 150/299). The cleanup is idempotent — when the
66
+ * fill already journaled + dropped state, the delayed drift is a no-op; when
67
+ * the fill never arrives (T-5 gap), the drift still heals, 10s late instead
68
+ * of instant (previously ≤5 min via the periodic sweep). */
69
+ const TRIGGER_DRIFT_GRACE_MS = 10_000;
70
+ /** Bound on the oid→cloid map (fills MAY omit `cloid` — facts ledger §3.4 —
71
+ * while `orderUpdates` always carries both, so the map backfills the fill's
72
+ * client id for bracket recognition). Insertion-ordered eviction. */
73
+ const OID_CLOID_MAP_MAX = 512;
58
74
  export class HyperliquidLiveAdapter extends EventEmitter {
59
75
  opts;
60
76
  api;
@@ -82,6 +98,10 @@ export class HyperliquidLiveAdapter extends EventEmitter {
82
98
  /** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
83
99
  * low-water mark the reconnect gap backfill widens from. */
84
100
  lastFillIngestMs = 0;
101
+ /** oid → cloid backfill for fills that omit `cloid` (the journal close path
102
+ * recognizes bracket legs by client id). Populated from `orderUpdates`,
103
+ * which always carries both. Bounded, insertion-order eviction. */
104
+ oidToCloid = new Map();
85
105
  /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
86
106
  * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
87
107
  * each balance fetch. Without this the skill self-computes a bogus anchor and
@@ -821,6 +841,19 @@ export class HyperliquidLiveAdapter extends EventEmitter {
821
841
  // healed it, in a repeating flap. Exchange truth is the resync's job.
822
842
  if (meta?.isSnapshot)
823
843
  return;
844
+ // Journal close capture: a close-direction fill (bracket SL/TP trigger,
845
+ // liquidation, external reduce) bypasses close_position, and before this
846
+ // wiring the journal only learned about it from the reconciler — 50% of
847
+ // wisekid's 30d closes were `reconciler_observed_flat` heals with the
848
+ // close reason lost. Route it through the SAME generic path Binance's
849
+ // ws-ingest uses; `handleReduceOnlyExit` posts the exact close when the
850
+ // position goes flat and defers to close_position/backstop otherwise.
851
+ // ONLY close-direction fills are routed: entry/scale-in fills stay with
852
+ // the synchronous create_order capture, because the WS dedup key
853
+ // (`openedFromExchangeTradeId === exchangeOrderId`) is unverified against
854
+ // ccxt's HL order.id shape and a dedup miss would re-mint the 38-duplicate-
855
+ // pairs class (issue #199 twin bug).
856
+ this.captureCloseFill(fill);
824
857
  try {
825
858
  // Matches the primary entry cid OR any additional cid recorded for a
826
859
  // scale-in / second resting entry (F5) — matching on `entryCid` alone
@@ -848,27 +881,82 @@ export class HyperliquidLiveAdapter extends EventEmitter {
848
881
  logger.error(TAG, `onUserFill handler error: ${formatError(err)}`);
849
882
  }
850
883
  }
884
+ /** Close-direction fill → position-decision journal (close-bypass fix, HL
885
+ * arm). Fire-and-forget: a journal POST blip must never touch the WS hot
886
+ * path. Reduce-only is derived from `dir` (HL fills carry no reduceOnly
887
+ * flag): "Close Long"/"Close Short", plus the liquidation marker. Flip
888
+ * dirs ("Long > Short") are NOT closes of a tracked side we understand —
889
+ * they stay with the reconciler backstop. */
890
+ captureCloseFill(fill) {
891
+ const capture = this.opts.autoCapture;
892
+ if (!capture)
893
+ return;
894
+ const dir = (fill.dir ?? '').toLowerCase();
895
+ const isClose = dir.startsWith('close') || fill.liquidation !== undefined;
896
+ if (!isClose)
897
+ return;
898
+ const price = Number(fill.px);
899
+ const size = Number(fill.sz);
900
+ if (!Number.isFinite(price) || price <= 0 || !Number.isFinite(size) || size <= 0)
901
+ return;
902
+ const realizedPnl = Number(fill.closedPnl);
903
+ const cloid = typeof fill.cloid === 'string' && fill.cloid.length > 0
904
+ ? fill.cloid
905
+ : this.oidToCloid.get(fill.oid);
906
+ onWsFillObserved(capture, {
907
+ symbol: hlCoinToCanonical(fill.coin),
908
+ side: fill.side === 'B' ? 'buy' : 'sell',
909
+ exchangeOrderId: String(fill.oid),
910
+ exchangeTradeId: String(fill.tid),
911
+ fillPrice: price,
912
+ fillSize: size,
913
+ reduceOnly: true,
914
+ realizedPnl: Number.isFinite(realizedPnl) ? realizedPnl : undefined,
915
+ clientOrderId: cloid,
916
+ exchangeTimeMs: Number.isFinite(fill.time) ? fill.time : undefined,
917
+ }).catch((err) => {
918
+ logger.warn(TAG, `journal close capture failed for ${fill.coin} oid=${fill.oid}: ${msg(err)}`);
919
+ });
920
+ }
851
921
  /** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
852
- * analog). A trigger = the exchange closed the position — surface the same
922
+ * analog). A trigger = the exchange closed the position — surface the
853
923
  * `drift_detected` close shape the Binance reconciler emits so the journal
854
- * close-bypass cleanup fires at once, not ≤5 min late. */
924
+ * close-bypass cleanup fires fast but AFTER a short grace, so the close
925
+ * FILL (the exact-close journal path, `captureCloseFill`) wins the
926
+ * undocumented orderUpdates/userFills frame ordering. The cleanup is
927
+ * idempotent: state already dropped by the fill path ⇒ no-op. */
855
928
  onUserOrderUpdate(update) {
856
929
  try {
930
+ // oid→cloid backfill for fills that omit their client id (see
931
+ // captureCloseFill). orderUpdates always carries both.
932
+ const { oid, cloid } = update.order;
933
+ if (typeof oid === 'number' && typeof cloid === 'string' && cloid.length > 0) {
934
+ this.oidToCloid.set(oid, cloid);
935
+ if (this.oidToCloid.size > OID_CLOID_MAP_MAX) {
936
+ const oldest = this.oidToCloid.keys().next().value;
937
+ if (oldest !== undefined)
938
+ this.oidToCloid.delete(oldest);
939
+ }
940
+ }
857
941
  const transition = this.getHlBracketCoordinator().handleOrderUpdate(update);
858
942
  if (transition === 'triggered_sl' || transition === 'triggered_tp' || transition === 'forced_close') {
859
943
  const row = this.getHlBracketCoordinator().getLedger().getAll().find((r) => update.order.cloid && (r.slCid === update.order.cloid || r.tpCid === update.order.cloid));
860
944
  const symbol = row?.symbol;
861
945
  if (symbol) {
862
- this.emit('drift_detected', {
863
- timestamp: new Date().toISOString(),
864
- drifts: [
865
- {
866
- type: 'closed',
867
- symbol,
868
- localContracts: row?.qty ?? 0,
869
- },
870
- ],
871
- });
946
+ const qty = row?.qty ?? 0;
947
+ const timer = setTimeout(() => {
948
+ this.emit('drift_detected', {
949
+ timestamp: new Date().toISOString(),
950
+ drifts: [
951
+ {
952
+ type: 'closed',
953
+ symbol,
954
+ localContracts: qty,
955
+ },
956
+ ],
957
+ });
958
+ }, TRIGGER_DRIFT_GRACE_MS);
959
+ timer.unref?.();
872
960
  }
873
961
  }
874
962
  }
package/dist/openclaw.js CHANGED
@@ -74,7 +74,7 @@ export function mergeReefClawConfig(input, connect = {}) {
74
74
  // --- tool profile widening ---
75
75
  // `openclaw setup` defaults tools.profile to "coding" (2026.6+), whose fixed
76
76
  // core allowlist filters out ALL plugin tools — the agent would see none of
77
- // the 62 trading tools. tools.alsoAllow explicitly widens the profile;
77
+ // the 66 trading tools. tools.alsoAllow explicitly widens the profile;
78
78
  // group:plugins covers every plugin-provided tool. Union, never replace.
79
79
  const existingAlsoAllow = Array.isArray(tools.alsoAllow) ? tools.alsoAllow.map(String) : [];
80
80
  if (!existingAlsoAllow.includes('group:plugins')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reefclaw/connect",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "bundle-assets": "node scripts/bundle-assets.mjs",
18
+ "prepack": "npm run build",
18
19
  "build": "tsc && node scripts/bundle-assets.mjs",
19
20
  "test": "vitest",
20
21
  "test:run": "vitest run"