@reefclaw/openclaw-plugin 0.1.24 → 0.1.25

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 (52) hide show
  1. package/bridge/connector.d.ts +3 -1
  2. package/bridge/connector.js +37 -2
  3. package/bridge/gateway/heartbeat-cron.js +2 -1
  4. package/bridge/index.js +21 -0
  5. package/bridge/shock-wake.d.ts +80 -0
  6. package/bridge/shock-wake.js +291 -0
  7. package/bridge/types.d.ts +4 -0
  8. package/bridge/utils/instance-id.d.ts +3 -0
  9. package/bridge/utils/instance-id.js +48 -0
  10. package/config/agent-config-client.d.ts +3 -1
  11. package/config/agent-config-client.js +4 -0
  12. package/config/brackets-config.d.ts +2 -1
  13. package/config/brackets-config.js +25 -3
  14. package/config/gate-store.d.ts +3 -0
  15. package/config/gate-store.js +11 -2
  16. package/config/loss-streak-config.d.ts +2 -0
  17. package/config/loss-streak-config.js +33 -0
  18. package/config/plugin-config-io.d.ts +19 -0
  19. package/config/reentry-cooldown-config.d.ts +7 -0
  20. package/config/reentry-cooldown-config.js +59 -0
  21. package/index.js +29 -2
  22. package/ingest/position-auto-capture.js +49 -4
  23. package/ingest/readiness-reporter.d.ts +23 -2
  24. package/ingest/readiness-reporter.js +56 -1
  25. package/onboarding/runtime.js +4 -0
  26. package/openclaw.plugin.json +1 -1
  27. package/package.json +2 -2
  28. package/portfolio/directional-scoreboard.d.ts +17 -0
  29. package/portfolio/directional-scoreboard.js +71 -0
  30. package/portfolio/reentry-tracker.d.ts +38 -1
  31. package/portfolio/reentry-tracker.js +49 -0
  32. package/signals/change-of-character.d.ts +38 -0
  33. package/signals/change-of-character.js +93 -0
  34. package/simulator/exchange-simulator.d.ts +5 -1
  35. package/simulator/exchange-simulator.js +24 -6
  36. package/simulator/types.d.ts +11 -0
  37. package/skills/reefclaw/SKILL.md +2 -2
  38. package/strategy/evaluator.d.ts +4 -0
  39. package/tools/close-position.js +10 -1
  40. package/tools/create-order.js +72 -2
  41. package/tools/hl-provision-agent-wallet.js +29 -11
  42. package/tools/reentry-cooldown.d.ts +33 -0
  43. package/tools/reentry-cooldown.js +74 -0
  44. package/tools/scan-pairs.d.ts +7 -0
  45. package/tools/scan-pairs.js +47 -0
  46. package/tools/set-exchange-credentials.js +19 -0
  47. package/tools/set-trading-mode.d.ts +6 -0
  48. package/tools/set-trading-mode.js +48 -1
  49. package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
  50. package/venues/hyperliquid/hl-agent-wallet.js +32 -0
  51. package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  52. package/venues/hyperliquid/hl-live-adapter.js +101 -13
@@ -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. ' +
package/bridge/index.js CHANGED
@@ -16,7 +16,9 @@ import { MockProvider } from './providers/mock.js';
16
16
  import { GatewayProvider } from './providers/gateway.js';
17
17
  import { resolveConfig } from './config.js';
18
18
  import { resolveGatewayConfig, validateGatewayConfig } from './gateway/gateway-config.js';
19
+ import { ShockWakePoller } from './shock-wake.js';
19
20
  import { runSetup } from './setup.js';
21
+ import { resolveRelayInstanceId } from './utils/instance-id.js';
20
22
  const TAG = 'main';
21
23
  // ---- Load .env file (skill/.env only) ----
22
24
  function loadEnvFile() {
@@ -191,6 +193,7 @@ async function main() {
191
193
  }
192
194
  // Create provider
193
195
  let provider;
196
+ let intelligenceUrlForWakes;
194
197
  if (args.provider === 'mock') {
195
198
  logger.info(TAG, 'Using MockProvider');
196
199
  provider = new MockProvider();
@@ -217,14 +220,30 @@ async function main() {
217
220
  gwConfig.connectionToken = token;
218
221
  logger.info(TAG, `Using GatewayProvider (url=${gwConfig.gatewayUrl}, symbol=${gwConfig.symbol})`);
219
222
  provider = new GatewayProvider(gwConfig);
223
+ intelligenceUrlForWakes = gwConfig.intelligenceUrl;
220
224
  }
221
225
  // Create bridge
222
226
  const connectorConfig = {
223
227
  relayUrl,
224
228
  userId,
225
229
  token,
230
+ // Stable per-install id → the relay's skill-slot admission recognises a
231
+ // same-box restart (instant takeover) vs a second box (rejected 4011).
232
+ instanceId: resolveRelayInstanceId(),
226
233
  };
227
234
  const bridge = new Bridge(provider, connectorConfig);
235
+ // Shock-wake poller (WS3, docs/MARKET_ADAPTIVITY_PLAN.md) — watches intel's
236
+ // /api/shocks (the WS2 change-of-character flags) and delivers an
237
+ // out-of-band re-evaluation turn to the agent on a genuine market shift.
238
+ // Inert until the central gate agent_config.gates.shockWake leaves 'off'.
239
+ const shockWake = intelligenceUrlForWakes
240
+ ? new ShockWakePoller({ provider, token, intelligenceUrl: intelligenceUrlForWakes })
241
+ : null;
242
+ if (shockWake) {
243
+ // Delivery confirmation: any agent turn STARTING proves the main-session
244
+ // lane is alive — the gateway ack alone cannot (swallowed-turn class).
245
+ provider.on('chatMessageStart', () => shockWake.noteAssistantActivity());
246
+ }
228
247
  // Handle graceful shutdown
229
248
  let shuttingDown = false;
230
249
  async function shutdown(signal) {
@@ -239,6 +258,7 @@ async function main() {
239
258
  }
240
259
  catch { /* best-effort */ }
241
260
  }
261
+ shockWake?.stop();
242
262
  bridge.stop();
243
263
  // Allow 1s for final cleanup before force exit
244
264
  setTimeout(() => process.exit(0), 1000);
@@ -248,5 +268,6 @@ async function main() {
248
268
  // Start
249
269
  logger.info(TAG, `Starting ReefClaw skill (provider=${args.provider}, relay=${relayUrl})`);
250
270
  bridge.start();
271
+ shockWake?.start();
251
272
  }
252
273
  main();
@@ -0,0 +1,80 @@
1
+ export type ShockWakeMode = 'off' | 'shadow' | 'on';
2
+ export interface ShockEntry {
3
+ symbol: string;
4
+ time: string;
5
+ flags: string[];
6
+ shock30mAtr: number;
7
+ range30mAtr: number;
8
+ return1hPct: number;
9
+ return4hPct: number;
10
+ tape4hAtr: number;
11
+ regime: string;
12
+ summary?: string;
13
+ }
14
+ /** 'BTCUSDT' / 'HL_BTC' / 'BTC/USDC' → 'BTC'. Best-effort market vocabulary
15
+ * for the wake message — never used to address an order. */
16
+ export declare function coinOf(intelSymbol: string): string;
17
+ /** Parse gates.shockWake off the raw /api/internal/config payload. Central-only
18
+ * gate — absent/garbage → 'off'. */
19
+ export declare function parseShockWakeGate(raw: unknown): ShockWakeMode;
20
+ export interface WakeAssessment {
21
+ wake: boolean;
22
+ reason?: 'leader_shock' | 'broad_shock';
23
+ /** Fresh 'shock'-flagged entries, extreme-first (as served). */
24
+ shocked: ShockEntry[];
25
+ }
26
+ /** Pure wake-worthiness rule. `nowMs` gates staleness. Broad rule (2026-09-06):
27
+ * ≥`minBroadCount` distinct coins shocked, OR ≥3 when a major participates. */
28
+ export declare function assessWakeWorthiness(shocks: ShockEntry[], nowMs: number, minBroadCount?: number): WakeAssessment;
29
+ /** The out-of-band agent turn. Clearly machine-labeled (never impersonates the
30
+ * operator), mandates re-evaluation, orders nothing. */
31
+ export declare function buildWakeMessage(a: WakeAssessment): string;
32
+ export interface ShockWakeOpts {
33
+ provider: {
34
+ handleChat(content: string): Promise<{
35
+ received: boolean;
36
+ error?: string;
37
+ }>;
38
+ };
39
+ token: string;
40
+ intelligenceUrl: string;
41
+ webappUrl?: string;
42
+ fetchImpl?: typeof fetch;
43
+ now?: () => number;
44
+ }
45
+ export declare class ShockWakePoller {
46
+ private readonly opts;
47
+ private mode;
48
+ private pollTimer;
49
+ private gateTimer;
50
+ private lastWakeAtMs;
51
+ private wakesToday;
52
+ private wakeDayUtc;
53
+ private polling;
54
+ private readonly pollMs;
55
+ private readonly gatePollMs;
56
+ private readonly cooldownMs;
57
+ private readonly dailyCap;
58
+ private readonly minBroad;
59
+ private readonly replyTimeoutMs;
60
+ private lastAssistantAtMs;
61
+ private awaitingSinceMs;
62
+ private awaitingDeadlineMs;
63
+ private suspectedSwallows;
64
+ constructor(opts: ShockWakeOpts);
65
+ start(): void;
66
+ stop(): void;
67
+ getMode(): ShockWakeMode;
68
+ /** Fed by the bridge from provider `chatMessageStart` — any agent turn
69
+ * beginning counts (the wake's reply, or an operator chat's; either proves
70
+ * the lane is alive, which is what the swallow check needs). */
71
+ noteAssistantActivity(atMs?: number): void;
72
+ /** Suspected-swallow count this process (test/telemetry seam). */
73
+ getSuspectedSwallows(): number;
74
+ private checkPendingReply;
75
+ private refreshGate;
76
+ /** One poll cycle. Exposed for tests. */
77
+ tick(): Promise<void>;
78
+ /** Test seam. */
79
+ __setModeForTest(mode: ShockWakeMode): void;
80
+ }
@@ -0,0 +1,291 @@
1
+ // Shock-wake poller — WS3 of docs/MARKET_ADAPTIVITY_PLAN.md.
2
+ //
3
+ // The agent thinks on a 15–30 min heartbeat; an announcement lands mid-cycle
4
+ // and isn't reconsidered until the next one — the adaptivity investigation's
5
+ // core latency gap. This poller watches intel's /api/shocks (the WS2
6
+ // change-of-character flags, computed each fact pass) and, when the market
7
+ // genuinely changes character, delivers ONE out-of-band agent turn through
8
+ // the same gateway path operator chat uses (provider.handleChat — the
9
+ // precedent is the trade-mission presenter).
10
+ //
11
+ // Guardrails (every one deliberately boring):
12
+ // - Central gate `agent_config.gates.shockWake`: off (default) → shadow
13
+ // (logs WOULD WAKE, delivers nothing) → on. Polled from the webapp
14
+ // every ~5 min; absent/garbage/outage → off. No local file fallback —
15
+ // this gate is central-only.
16
+ // - Wake-worthy = a market LEADER (BTC/ETH) flagged 'shock', OR a broad
17
+ // correlated move: ≥5 distinct coins shocked (env RC_SHOCK_WAKE_MIN_BROAD),
18
+ // or ≥3 when a MAJOR (BTC/ETH/BNB/SOL/XRP) is among them. Disagreement-only
19
+ // flags never wake — they are slow-moving and already ride scan_pairs
20
+ // indication. (Broad bar raised from a flat ≥3 on 2026-09-06: the first
21
+ // live night delivered 7 wakes on rotating 3-coin mid-cap alt clusters —
22
+ // routine alt volatility, not announcements. Replayed against the new
23
+ // rule, those 7 become 1.)
24
+ // - Stale shocks (fact older than 10 min) are ignored.
25
+ // - Global cooldown 60 min between wakes + daily cap 8 (UTC), armed in
26
+ // shadow too so the shadow soak counts exactly what 'on' would deliver.
27
+ // A FAILED delivery does not arm the cooldown (retry next tick).
28
+ // (Cooldown default raised 30→60 min 2026-09-05: the shadow soak showed a
29
+ // single sustained 09-03 event re-firing 4 would-wakes in 95 min — one
30
+ // re-ping per hour during a long move is the intended cadence.)
31
+ // - The wake is INDICATION: it mandates re-evaluation, never an action.
32
+ // - DELIVERY CONFIRMATION (2026-09-06): the gateway acks the RPC even when
33
+ // the main-session lane silently swallows the turn (the 2026-07-22
34
+ // OpenClaw class — 7 of the first night's 9 wakes died this way). The
35
+ // bridge wires provider `chatMessageStart` into noteAssistantActivity();
36
+ // if no agent turn STARTS within RC_SHOCK_WAKE_REPLY_TIMEOUT_MS (10 min)
37
+ // of a delivered wake, a WARN names the swallow suspicion (dashboard chat
38
+ // is likely dead too) and a counter increments. Observability only — no
39
+ // auto-retry into a wedged lane.
40
+ //
41
+ // Env overrides: RC_SHOCK_WAKE_POLL_MS (60s), RC_SHOCK_WAKE_GATE_POLL_MS
42
+ // (5 min), RC_SHOCK_WAKE_COOLDOWN_MS (30 min), RC_SHOCK_WAKE_DAILY_CAP (8).
43
+ import { logger, formatError } from './logger.js';
44
+ const TAG = 'shock-wake';
45
+ /** Leaders whose lone shock is wake-worthy, as base coins (venue-stripped). */
46
+ const LEADER_COINS = new Set(['BTC', 'ETH']);
47
+ /** Majors (the platform's liquidation-pulse majors group): their participation
48
+ * lowers the broad-shock coin bar from 5 to 3 — a SOL-led cluster is market
49
+ * information; three rotating meme-alts are Tuesday. */
50
+ const MAJOR_COINS = new Set(['BTC', 'ETH', 'BNB', 'SOL', 'XRP']);
51
+ const STALE_SHOCK_MS = 10 * 60_000;
52
+ function envInt(name, fallback) {
53
+ const v = Number(process.env[name]);
54
+ return Number.isFinite(v) && v > 0 ? v : fallback;
55
+ }
56
+ /** 'BTCUSDT' / 'HL_BTC' / 'BTC/USDC' → 'BTC'. Best-effort market vocabulary
57
+ * for the wake message — never used to address an order. */
58
+ export function coinOf(intelSymbol) {
59
+ return intelSymbol
60
+ .replace(/^HL_/, '')
61
+ .replace(/\/.*$/, '')
62
+ .replace(/(USDT|USDC|BUSD)$/i, '')
63
+ .toUpperCase();
64
+ }
65
+ /** Parse gates.shockWake off the raw /api/internal/config payload. Central-only
66
+ * gate — absent/garbage → 'off'. */
67
+ export function parseShockWakeGate(raw) {
68
+ if (!raw || typeof raw !== 'object')
69
+ return 'off';
70
+ const gates = raw.gates;
71
+ if (!gates || typeof gates !== 'object')
72
+ return 'off';
73
+ const v = gates.shockWake;
74
+ return v === 'shadow' || v === 'on' ? v : 'off';
75
+ }
76
+ /** Pure wake-worthiness rule. `nowMs` gates staleness. Broad rule (2026-09-06):
77
+ * ≥`minBroadCount` distinct coins shocked, OR ≥3 when a major participates. */
78
+ export function assessWakeWorthiness(shocks, nowMs, minBroadCount = 5) {
79
+ const fresh = shocks.filter((s) => {
80
+ const t = Date.parse(s.time);
81
+ return Number.isFinite(t) && nowMs - t <= STALE_SHOCK_MS;
82
+ });
83
+ const shocked = fresh.filter((s) => s.flags.includes('shock'));
84
+ // One coin can appear per-venue (BTCUSDT + HL_BTC) — count coins, not rows.
85
+ const coins = new Set(shocked.map((s) => coinOf(s.symbol)));
86
+ const leader = shocked.find((s) => LEADER_COINS.has(coinOf(s.symbol)));
87
+ if (leader)
88
+ return { wake: true, reason: 'leader_shock', shocked };
89
+ const hasMajor = [...coins].some((c) => MAJOR_COINS.has(c));
90
+ if (coins.size >= minBroadCount || (hasMajor && coins.size >= 3)) {
91
+ return { wake: true, reason: 'broad_shock', shocked };
92
+ }
93
+ return { wake: false, shocked };
94
+ }
95
+ /** The out-of-band agent turn. Clearly machine-labeled (never impersonates the
96
+ * operator), mandates re-evaluation, orders nothing. */
97
+ export function buildWakeMessage(a) {
98
+ const seen = new Set();
99
+ const lines = [];
100
+ for (const s of a.shocked) {
101
+ const coin = coinOf(s.symbol);
102
+ if (seen.has(coin))
103
+ continue;
104
+ seen.add(coin);
105
+ const sign = (v) => (v >= 0 ? '+' : '');
106
+ lines.push(`- ${coin}: 30m ${Math.max(s.shock30mAtr, s.range30mAtr).toFixed(1)}×ATR, ` +
107
+ `1h ${sign(s.return1hPct)}${s.return1hPct}%, 4h ${sign(s.return4hPct)}${s.return4hPct}% ` +
108
+ `(regime label: ${s.regime})`);
109
+ if (lines.length >= 6)
110
+ break;
111
+ }
112
+ return [
113
+ '[AUTOMATED MARKET-SHIFT ALERT — from the ReefClaw system, NOT the operator]',
114
+ '',
115
+ a.reason === 'leader_shock'
116
+ ? 'A market leader just moved sharply — the market may have changed character:'
117
+ : 'Multiple symbols moved sharply together — the market may have changed character:',
118
+ ...lines,
119
+ '',
120
+ 'Do this NOW, ahead of your scheduled heartbeat:',
121
+ '1. Re-derive your directional bias from FRESH data (scan_pairs, get_regime) — do NOT lean on theses formed before this move; regime labels may LAG it (see market_shift_caution in scan results).',
122
+ '2. For EVERY open position: check the pinned invalidation FIRST. If price is through it, act on your own plan.',
123
+ '3. Hold off on new entries until the re-derived bias and the tape agree.',
124
+ '',
125
+ 'This alert is indication only — it does not order you to close anything. Reply with a brief assessment so the operator sees your read.',
126
+ ].join('\n');
127
+ }
128
+ export class ShockWakePoller {
129
+ opts;
130
+ mode = 'off';
131
+ pollTimer = null;
132
+ gateTimer = null;
133
+ lastWakeAtMs = 0;
134
+ wakesToday = 0;
135
+ wakeDayUtc = '';
136
+ polling = false;
137
+ pollMs = envInt('RC_SHOCK_WAKE_POLL_MS', 60_000);
138
+ gatePollMs = envInt('RC_SHOCK_WAKE_GATE_POLL_MS', 5 * 60_000);
139
+ cooldownMs = envInt('RC_SHOCK_WAKE_COOLDOWN_MS', 60 * 60_000);
140
+ dailyCap = envInt('RC_SHOCK_WAKE_DAILY_CAP', 8);
141
+ minBroad = envInt('RC_SHOCK_WAKE_MIN_BROAD', 5);
142
+ replyTimeoutMs = envInt('RC_SHOCK_WAKE_REPLY_TIMEOUT_MS', 10 * 60_000);
143
+ // Delivery confirmation (see header): a wake is only proven delivered when
144
+ // an agent turn STARTS after it. lastAssistantAtMs is fed by the bridge's
145
+ // chatMessageStart wiring; awaitingSince/deadline track one in-flight wake
146
+ // (the 60-min cooldown guarantees no overlap with the 10-min timeout).
147
+ lastAssistantAtMs = 0;
148
+ awaitingSinceMs = null;
149
+ awaitingDeadlineMs = 0;
150
+ suspectedSwallows = 0;
151
+ constructor(opts) {
152
+ this.opts = opts;
153
+ }
154
+ start() {
155
+ if (this.pollTimer)
156
+ return;
157
+ const refresh = () => { void this.refreshGate(); };
158
+ refresh();
159
+ this.gateTimer = setInterval(refresh, this.gatePollMs);
160
+ this.gateTimer.unref?.();
161
+ this.pollTimer = setInterval(() => { void this.tick(); }, this.pollMs);
162
+ this.pollTimer.unref?.();
163
+ logger.info(TAG, `started (poll ${this.pollMs / 1000}s, cooldown ${this.cooldownMs / 60000}m, cap ${this.dailyCap}/day, gate polled ${this.gatePollMs / 60000}m)`);
164
+ }
165
+ stop() {
166
+ if (this.pollTimer)
167
+ clearInterval(this.pollTimer);
168
+ if (this.gateTimer)
169
+ clearInterval(this.gateTimer);
170
+ this.pollTimer = null;
171
+ this.gateTimer = null;
172
+ }
173
+ getMode() {
174
+ return this.mode;
175
+ }
176
+ /** Fed by the bridge from provider `chatMessageStart` — any agent turn
177
+ * beginning counts (the wake's reply, or an operator chat's; either proves
178
+ * the lane is alive, which is what the swallow check needs). */
179
+ noteAssistantActivity(atMs) {
180
+ const t = atMs ?? this.opts.now?.() ?? Date.now();
181
+ this.lastAssistantAtMs = t;
182
+ if (this.awaitingSinceMs !== null && t >= this.awaitingSinceMs) {
183
+ logger.info(TAG, `wake reply confirmed — agent turn started ${Math.round((t - this.awaitingSinceMs) / 1000)}s after delivery`);
184
+ this.awaitingSinceMs = null;
185
+ }
186
+ }
187
+ /** Suspected-swallow count this process (test/telemetry seam). */
188
+ getSuspectedSwallows() {
189
+ return this.suspectedSwallows;
190
+ }
191
+ checkPendingReply(nowMs) {
192
+ if (this.awaitingSinceMs === null || nowMs <= this.awaitingDeadlineMs)
193
+ return;
194
+ this.suspectedSwallows++;
195
+ logger.warn(TAG, `wake delivered at ${new Date(this.awaitingSinceMs).toISOString()} but NO agent turn started ` +
196
+ `within ${Math.round(this.replyTimeoutMs / 60_000)}m — the main-session lane may be silently ` +
197
+ `swallowing turns (known OpenClaw class, 2026-07-22; dashboard chat is likely affected too). ` +
198
+ `Suspected swallows this process: ${this.suspectedSwallows}`);
199
+ this.awaitingSinceMs = null;
200
+ }
201
+ async refreshGate() {
202
+ const base = (this.opts.webappUrl ?? process.env.REEFCLAW_API_URL ?? 'https://www.reefclaw.com').replace(/\/$/, '');
203
+ try {
204
+ const doFetch = this.opts.fetchImpl ?? fetch;
205
+ const res = await doFetch(`${base}/api/internal/config`, {
206
+ headers: { Authorization: `Bearer ${this.opts.token}` },
207
+ });
208
+ if (!res.ok)
209
+ return; // keep last-known mode on outage (fail toward inert)
210
+ const next = parseShockWakeGate(await res.json());
211
+ if (next !== this.mode) {
212
+ logger.info(TAG, `gate: shockWake ${this.mode} → ${next}`);
213
+ this.mode = next;
214
+ }
215
+ }
216
+ catch (err) {
217
+ logger.debug(TAG, `gate poll failed (keeping ${this.mode}): ${formatError(err)}`);
218
+ }
219
+ }
220
+ /** One poll cycle. Exposed for tests. */
221
+ async tick() {
222
+ const tickNowMs = this.opts.now?.() ?? Date.now();
223
+ // Runs even when the gate is off — a wake delivered just before an
224
+ // operator rollback still deserves its confirmation verdict.
225
+ this.checkPendingReply(tickNowMs);
226
+ if (this.mode === 'off' || this.polling)
227
+ return;
228
+ this.polling = true;
229
+ try {
230
+ const nowMs = tickNowMs;
231
+ const doFetch = this.opts.fetchImpl ?? fetch;
232
+ const base = this.opts.intelligenceUrl.replace(/\/$/, '');
233
+ const res = await doFetch(`${base}/api/shocks`, {
234
+ headers: { Authorization: `Bearer ${this.opts.token}` },
235
+ });
236
+ if (!res.ok)
237
+ return;
238
+ const body = (await res.json());
239
+ if (!Array.isArray(body?.shocks))
240
+ return;
241
+ const a = assessWakeWorthiness(body.shocks, nowMs, this.minBroad);
242
+ if (!a.wake)
243
+ return;
244
+ // Rate limits — checked only for wake-worthy ticks so quiet markets
245
+ // never touch the counters.
246
+ const day = new Date(nowMs).toISOString().slice(0, 10);
247
+ if (day !== this.wakeDayUtc) {
248
+ this.wakeDayUtc = day;
249
+ this.wakesToday = 0;
250
+ }
251
+ if (nowMs - this.lastWakeAtMs < this.cooldownMs)
252
+ return;
253
+ if (this.wakesToday >= this.dailyCap) {
254
+ logger.warn(TAG, `daily cap ${this.dailyCap} reached — suppressing further wakes today`);
255
+ return;
256
+ }
257
+ const coins = [...new Set(a.shocked.map((s) => coinOf(s.symbol)))].join(', ');
258
+ if (this.mode === 'shadow') {
259
+ this.lastWakeAtMs = nowMs;
260
+ this.wakesToday++;
261
+ logger.warn(TAG, `WOULD WAKE (shadow): ${a.reason} — ${coins} (${this.wakesToday}/${this.dailyCap} today)`);
262
+ return;
263
+ }
264
+ const message = buildWakeMessage(a);
265
+ const out = await this.opts.provider.handleChat(message);
266
+ if (out.received) {
267
+ this.lastWakeAtMs = nowMs;
268
+ this.wakesToday++;
269
+ // Arm the delivery-confirmation window (see checkPendingReply).
270
+ this.awaitingSinceMs = nowMs;
271
+ this.awaitingDeadlineMs = nowMs + this.replyTimeoutMs;
272
+ logger.warn(TAG, `WAKE delivered: ${a.reason} — ${coins} (${this.wakesToday}/${this.dailyCap} today)`);
273
+ }
274
+ else {
275
+ // Delivery failed (gateway not connected etc.) — no cooldown, retry
276
+ // on the next tick while the shock is still fresh.
277
+ logger.warn(TAG, `wake delivery FAILED (${out.error ?? 'unknown'}) — will retry next tick`);
278
+ }
279
+ }
280
+ catch (err) {
281
+ logger.debug(TAG, `tick failed: ${formatError(err)}`);
282
+ }
283
+ finally {
284
+ this.polling = false;
285
+ }
286
+ }
287
+ /** Test seam. */
288
+ __setModeForTest(mode) {
289
+ this.mode = mode;
290
+ }
291
+ }
package/bridge/types.d.ts CHANGED
@@ -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
+ }
@@ -6,12 +6,14 @@
6
6
  * `positionReviewMode` (slice 3, the Position Decision Journal
7
7
  * heartbeat-mandate + superset gate, file key `positionReview.mode`) →
8
8
  * `approvalMode` (slice 4, per-trade operator approval, file key
9
- * `approval.mode`). The first two ride the same four-stage
9
+ * `approval.mode`) `reentryCooldown` (create_order re-entry cooldown, file
10
+ * key `reentryCooldown.mode`). All but approvalMode ride the same four-stage
10
11
  * `off → shadow → observe → enforce` ladder; approvalMode has its own. */
11
12
  export interface AgentGates {
12
13
  exitGate?: 'off' | 'shadow' | 'observe' | 'enforce';
13
14
  positionReviewMode?: 'off' | 'shadow' | 'observe' | 'enforce';
14
15
  approvalMode?: 'off' | 'per_trade';
16
+ reentryCooldown?: 'off' | 'shadow' | 'observe' | 'enforce';
15
17
  }
16
18
  /** Server-resolved entitlement verdict (webapp lib/entitlements.ts, computed
17
19
  * from the users row and delivered on the config channel). The plugin NEVER
@@ -125,6 +125,10 @@ function validateGates(raw) {
125
125
  if (typeof approvalMode === 'string' && APPROVAL_MODE_VALUES.has(approvalMode)) {
126
126
  gates.approvalMode = approvalMode;
127
127
  }
128
+ const reentryCooldown = obj.reentryCooldown;
129
+ if (typeof reentryCooldown === 'string' && MODE_LADDER_VALUES.has(reentryCooldown)) {
130
+ gates.reentryCooldown = reentryCooldown;
131
+ }
128
132
  return gates;
129
133
  }
130
134
  /** Version-monotonic acceptance (basic rollback/replay protection): a fetched
@@ -1,6 +1,7 @@
1
1
  import { type PluginConfigFile } from './plugin-config-io.js';
2
2
  export type BracketMode = 'off' | 'observe' | 'enforce';
3
- /** Read bracket mode from a config object. Invalid values fall back to 'off'. */
3
+ /** Read bracket mode from a config object. Invalid values fall back to the
4
+ * context default ('enforce' on a live-Binance box, else 'off'). */
4
5
  export declare function getBracketMode(config?: PluginConfigFile, remoteOverride?: BracketMode): BracketMode;
5
6
  /** Convenience: load from disk and return the effective mode. */
6
7
  export declare function loadBracketMode(): BracketMode;