@reefclaw/openclaw-plugin 0.1.23 → 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 (95) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.d.ts +3 -1
  3. package/bridge/connector.js +51 -4
  4. package/bridge/gateway/heartbeat-cron.js +31 -7
  5. package/bridge/gateway/poller.d.ts +5 -0
  6. package/bridge/gateway/poller.js +9 -0
  7. package/bridge/index.js +21 -0
  8. package/bridge/provider.d.ts +15 -0
  9. package/bridge/providers/connector-update.d.ts +89 -0
  10. package/bridge/providers/connector-update.js +212 -0
  11. package/bridge/providers/emergency-commands.d.ts +36 -0
  12. package/bridge/providers/emergency-commands.js +91 -0
  13. package/bridge/providers/gateway.d.ts +26 -1
  14. package/bridge/providers/gateway.js +159 -8
  15. package/bridge/providers/mock.js +1 -0
  16. package/bridge/shock-wake.d.ts +80 -0
  17. package/bridge/shock-wake.js +291 -0
  18. package/bridge/types.d.ts +5 -1
  19. package/bridge/types.js +5 -0
  20. package/bridge/utils/instance-id.d.ts +3 -0
  21. package/bridge/utils/instance-id.js +48 -0
  22. package/ccxt/binance-private.js +2 -1
  23. package/ccxt/binance-public.js +6 -1
  24. package/config/agent-config-client.d.ts +7 -2
  25. package/config/agent-config-client.js +17 -0
  26. package/config/agent-config-poller.js +5 -1
  27. package/config/brackets-config.d.ts +2 -1
  28. package/config/brackets-config.js +25 -3
  29. package/config/gate-store.d.ts +12 -0
  30. package/config/gate-store.js +26 -2
  31. package/config/loss-streak-config.d.ts +2 -0
  32. package/config/loss-streak-config.js +33 -0
  33. package/config/plugin-config-io.d.ts +19 -0
  34. package/config/plugin-config-io.js +24 -2
  35. package/config/reentry-cooldown-config.d.ts +7 -0
  36. package/config/reentry-cooldown-config.js +59 -0
  37. package/http/keepalive-fetch.d.ts +5 -0
  38. package/http/keepalive-fetch.js +50 -0
  39. package/index.js +77 -8
  40. package/ingest/position-auto-capture.js +49 -4
  41. package/ingest/position-decisions-client.d.ts +6 -0
  42. package/ingest/position-decisions-client.js +27 -9
  43. package/ingest/readiness-reporter.d.ts +23 -2
  44. package/ingest/readiness-reporter.js +56 -1
  45. package/live/approval-lifecycle.d.ts +10 -0
  46. package/live/approval-lifecycle.js +16 -2
  47. package/live/microstructure-assembler.js +11 -2
  48. package/live/proposal-decision-listener.d.ts +21 -0
  49. package/live/proposal-decision-listener.js +39 -0
  50. package/live/proposal-manager.d.ts +12 -0
  51. package/live/proposal-manager.js +47 -0
  52. package/live/stop-watcher.d.ts +16 -1
  53. package/live/stop-watcher.js +48 -8
  54. package/onboarding/runtime.js +4 -0
  55. package/openclaw.plugin.json +1 -1
  56. package/package.json +38 -38
  57. package/persistence/state-manager.d.ts +7 -0
  58. package/persistence/state-manager.js +28 -1
  59. package/portfolio/directional-scoreboard.d.ts +17 -0
  60. package/portfolio/directional-scoreboard.js +71 -0
  61. package/portfolio/reentry-tracker.d.ts +38 -1
  62. package/portfolio/reentry-tracker.js +49 -0
  63. package/signals/change-of-character.d.ts +38 -0
  64. package/signals/change-of-character.js +93 -0
  65. package/simulator/exchange-simulator.d.ts +27 -1
  66. package/simulator/exchange-simulator.js +98 -38
  67. package/simulator/types.d.ts +11 -0
  68. package/skills/reefclaw/SKILL.md +2 -2
  69. package/strategy/evaluator.d.ts +4 -0
  70. package/tools/audit-bracket-protection.js +11 -7
  71. package/tools/close-position.js +10 -1
  72. package/tools/create-order.js +121 -9
  73. package/tools/get-funding-context.js +6 -1
  74. package/tools/get-liquidation-levels.js +5 -1
  75. package/tools/get-liquidation-pulse.js +7 -1
  76. package/tools/get-market-intel.js +2 -1
  77. package/tools/get-relevant-learnings.js +20 -1
  78. package/tools/get-resting-liquidity.js +6 -1
  79. package/tools/get-wave9-status.js +17 -0
  80. package/tools/hl-provision-agent-wallet.js +29 -11
  81. package/tools/intel-api.d.ts +9 -0
  82. package/tools/intel-api.js +32 -1
  83. package/tools/record-position-reviews.js +2 -2
  84. package/tools/reentry-cooldown.d.ts +33 -0
  85. package/tools/reentry-cooldown.js +74 -0
  86. package/tools/scan-pairs.d.ts +7 -0
  87. package/tools/scan-pairs.js +67 -11
  88. package/tools/set-exchange-credentials.js +19 -0
  89. package/tools/set-trading-mode.d.ts +6 -0
  90. package/tools/set-trading-mode.js +48 -1
  91. package/types.d.ts +7 -0
  92. package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
  93. package/venues/hyperliquid/hl-agent-wallet.js +32 -0
  94. package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  95. package/venues/hyperliquid/hl-live-adapter.js +101 -13
package/bridge/bridge.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import { writeFileSync, renameSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { homedir } from 'os';
14
- import { logger } from './logger.js';
14
+ import { logger, formatError } from './logger.js';
15
15
  import { Connector } from './connector.js';
16
16
  import { readLocalSkillVersion, readAgentVisibleSkillVersions, validateSkillContent, compareSemver } from './utils/skill-version.js';
17
17
  import { invalidateSkillsSnapshot } from './utils/skills-snapshot-invalidation.js';
@@ -53,6 +53,8 @@ const CRITICAL_EVENTS = new Set([
53
53
  'state_update', // Agent mode changes (ACTIVE/PAUSED/STOPPED)
54
54
  'trading_mode', // Paper/live mode changes
55
55
  'skill_update_applied', // OTA SKILL.md confirmation
56
+ 'connector_update', // Operator-triggered update progress — the frame right
57
+ // before the gateway restart must not be coalesced away.
56
58
  // All chat events are on the 'chat' channel and always sent immediately
57
59
  ]);
58
60
  /** How often to re-attempt the webapp SKILL.md pull while still on a bootstrap
@@ -249,6 +251,14 @@ export class Bridge {
249
251
  this.addListener('decisionTraceUpdate', (data) => {
250
252
  this.emit('agent_state', 'decision_trace', data);
251
253
  });
254
+ // Operator-triggered connector update: PTY output + status. CRITICAL
255
+ // priority so the operator watching an update in progress sees it live
256
+ // instead of behind the 500ms low-priority coalescer — and, more to the
257
+ // point, so the final frame before the gateway restart is not the one that
258
+ // gets dropped.
259
+ this.addListener('connectorUpdate', (data) => {
260
+ this.emit('agent_state', 'connector_update', data);
261
+ });
252
262
  }
253
263
  addListener(event, fn) {
254
264
  this.provider.on(event, fn);
@@ -313,10 +323,20 @@ export class Bridge {
313
323
  flushThrottleBuffer() {
314
324
  if (this.throttleBuffer.size === 0)
315
325
  return;
316
- for (const [, { channel, event, payload }] of this.throttleBuffer) {
317
- this.sendFrame(channel, event, payload);
326
+ // Per-entry guard + delete-as-sent: this runs bare inside setInterval, so
327
+ // an uncaught throw (e.g. JSON.stringify on a payload with a circular ref
328
+ // or BigInt leaking out of a tool result) would be a process-killing
329
+ // uncaught exception — and skipping clear() would re-flush the survivors
330
+ // next tick under NEW seq numbers (duplicate frames on the wire).
331
+ for (const [key, { channel, event, payload }] of this.throttleBuffer) {
332
+ this.throttleBuffer.delete(key);
333
+ try {
334
+ this.sendFrame(channel, event, payload);
335
+ }
336
+ catch (err) {
337
+ logger.warn(TAG, `flush dropped unserializable ${channel}:${event}: ${formatError(err)}`);
338
+ }
318
339
  }
319
- this.throttleBuffer.clear();
320
340
  }
321
341
  startThrottleTimer() {
322
342
  this.stopThrottleTimer();
@@ -361,7 +381,7 @@ export class Bridge {
361
381
  logger.info(TAG, 'Reconciliation requested');
362
382
  const snapshot = await this.provider.getSnapshot();
363
383
  const lastSequences = parseLastSequences(params);
364
- const gapFill = this.replayBuffer
384
+ let gapFill = this.replayBuffer
365
385
  .sinceMany(lastSequences)
366
386
  .map((e) => ({
367
387
  type: 'event',
@@ -370,7 +390,32 @@ export class Bridge {
370
390
  payload: e.payload,
371
391
  seq: e.seq,
372
392
  }));
393
+ // The whole reconcile response is ONE WS message; an unbounded replay
394
+ // blows the relay's frame limit and the browser then gets NO snapshot
395
+ // at all (observed live 2026-08-04: 369 replayed events → relay
396
+ // "Message too large"). Keep the NEWEST events under a byte budget —
397
+ // the snapshot already carries current state, so dropping the oldest
398
+ // stream events is strictly better than losing the entire response.
373
399
  if (gapFill.length > 0) {
400
+ const GAP_FILL_BYTE_BUDGET = 256 * 1024;
401
+ let bytes = 0;
402
+ let start = gapFill.length;
403
+ for (let i = gapFill.length - 1; i >= 0; i--) {
404
+ try {
405
+ bytes += JSON.stringify(gapFill[i]).length;
406
+ }
407
+ catch {
408
+ start = i + 1; // unserializable event — cut it and everything older
409
+ break;
410
+ }
411
+ if (bytes > GAP_FILL_BYTE_BUDGET)
412
+ break;
413
+ start = i;
414
+ }
415
+ if (start > 0) {
416
+ logger.warn(TAG, `gapFill capped: dropping ${start} oldest of ${gapFill.length} events to fit the relay frame limit`);
417
+ gapFill = gapFill.slice(start);
418
+ }
374
419
  logger.info(TAG, `gapFill: ${gapFill.length} events replayed (lastSeqs=${JSON.stringify(lastSequences)})`);
375
420
  }
376
421
  // Provider hardcodes gapFill: []; bridge owns the wire-side seq
@@ -557,6 +602,28 @@ export class Bridge {
557
602
  this.connector.sendResponse(id, true, result);
558
603
  return;
559
604
  }
605
+ if (method === 'connector.update') {
606
+ const provider = this.provider;
607
+ if (!provider.updateConnector) {
608
+ this.connector.sendResponse(id, true, {
609
+ ok: false,
610
+ status: 'failed',
611
+ message: 'Provider does not support connector.update (likely mock provider)',
612
+ });
613
+ return;
614
+ }
615
+ // ★ Deliberately reads ONE field. There is no command/version/args
616
+ // parameter to forward — the command is a constant inside the driver
617
+ // (providers/connector-update.ts). If a future change starts forwarding
618
+ // caller-supplied strings into that command, this stops being an update
619
+ // button and becomes a remote shell on the trader's machine.
620
+ const acknowledgeOpenPositions = params?.acknowledgeOpenPositions === true;
621
+ audit('connector_update.start', { id, acknowledgeOpenPositions });
622
+ const outcome = await provider.updateConnector({ acknowledgeOpenPositions });
623
+ audit('connector_update.dispatched', { id, ok: outcome.ok, status: outcome.status });
624
+ this.connector.sendResponse(id, true, outcome);
625
+ return;
626
+ }
560
627
  if (method === 'get_bracket_config') {
561
628
  if (!this.provider.getBracketConfig) {
562
629
  this.connector.sendResponse(id, false, undefined, {
@@ -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() {
@@ -78,10 +83,40 @@ export class Connector {
78
83
  this.rejectAllPending('Connection closed');
79
84
  if (this.destroyed)
80
85
  return;
81
- // Fatal codes: do not reconnect
86
+ // Fatal codes (4001 invalid/mismatched token): no fast reconnect — but
87
+ // not terminal-forever either. A 4001 can be transient (relay/DB blip,
88
+ // token-cache miss on a just-created token, revoke-then-restore), and
89
+ // the old return-and-never-retry left the dashboard dark until a manual
90
+ // bridge restart (residual half of the #312 gave-up-forever class). One
91
+ // probe per 15 min is harmless and self-heals the transient cases.
82
92
  if (FATAL_CLOSE_CODES.includes(code)) {
83
- logger.error(TAG, `Fatal close code ${code} not reconnecting`);
93
+ logger.error(TAG, `Fatal close code ${code} (invalid/mismatched token). If the token was rotated, ` +
94
+ `update the bridge .env (REEFCLAW_TOKEN) and restart. Probing again in 15 min ` +
95
+ `in case the rejection was transient.`);
84
96
  this.setState('failed');
97
+ this.attempt = 0;
98
+ this.reconnectTimer = setTimeout(() => {
99
+ this.reconnectTimer = null;
100
+ this.connect();
101
+ }, 15 * 60_000);
102
+ return;
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);
85
120
  return;
86
121
  }
87
122
  // 4002 = stale skill connection on relay. Wait for it to time out, then retry.
@@ -263,6 +298,18 @@ export class Connector {
263
298
  // Send a native WebSocket ping (protocol-level, handled by PartyKit automatically).
264
299
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
265
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
+ }));
266
313
  }
267
314
  }, this.heartbeatConfig.intervalMs);
268
315
  logger.debug(TAG, `Heartbeat started: interval=${this.heartbeatConfig.intervalMs}ms timeout=${this.heartbeatConfig.timeoutMs}ms`);
@@ -28,11 +28,35 @@ export const HEARTBEAT_EVERY_MS = 15 * 60 * 1000;
28
28
  // (verified empirically on 2026.7.1-2: a cron.add without it stores none) —
29
29
  // 780s is the field-proven value from the 2026-07-22 small-box mitigations.
30
30
  export const HEARTBEAT_TIMEOUT_SECONDS = 780;
31
- // Byte-identical to the message the setup-time CLI path created, so new jobs
32
- // match every existing install's job.
33
- export const HEARTBEAT_MESSAGE = 'Heartbeat. Run SESSION START mandatory checks, then the full Decision Loop (Steps 0-9) from SKILL.md. ' +
34
- 'Check tradingMode from fetch_balance it is your source of truth for paper vs live. ' +
35
- 'Use tools in parallel where possible.';
31
+ // The routine beat is HEARTBEAT.md (injected into every isolated cron session
32
+ // as workspace bootstrap context) — NOT the SKILL.md Decision Loop. The old
33
+ // message ("run the full Decision Loop (Steps 0-9) from SKILL.md") had two
34
+ // measured costs on prod (2026-08-04): the agent paged the whole ~110KB
35
+ // SKILL.md through sed/rg every beat (~100KB of tool output before any
36
+ // trading work), and Steps 0-9 predate the pinned-plan/exit-gate discipline —
37
+ // Step 8's exit rules are the scratch-the-winners instructions. SKILL.md is
38
+ // consulted only when preparing a NEW entry.
39
+ // NOTE: once-per-install marker semantics mean this message only reaches NEW
40
+ // installs; existing boxes need a one-time `openclaw cron edit <id> --message`
41
+ // (done on prod at deploy).
42
+ // ★ The mandatory items are ENUMERATED here on purpose. The first (softer)
43
+ // version of this message — "Run the HEARTBEAT.md checklist" — produced
44
+ // 85-second beats on gpt-5.4-mini that skipped record_position_reviews,
45
+ // get_wave9_status, and the liquidity batch entirely (observed on prod
46
+ // 2026-08-04 12:25/12:40 UTC): the checklist being in context is not enough,
47
+ // the beat prompt itself must name what cannot be skipped.
48
+ export const HEARTBEAT_MESSAGE = 'Heartbeat. Execute EVERY checkbox in the HEARTBEAT.md checklist (already injected in your context; ' +
49
+ 'do NOT re-read SKILL.md from disk unless preparing a NEW entry). ' +
50
+ 'NON-SKIPPABLE every beat: (1) query_trades({hours:1}) stop-watcher reconcile; ' +
51
+ '(2) get_wave9_status() once, unconditionally; ' +
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, ' +
54
+ 'then record_position_reviews with ONE review per open position, ' +
55
+ 'plus get_resting_liquidity + get_liquidation_levels + get_liquidation_pulse for ALL positions in one parallel batch; ' +
56
+ '(4) the Market Assessment reads. ' +
57
+ 'Manage open positions by their pinned plan (invalidation_price / realization_rule) — do not re-argue the entry thesis each beat. ' +
58
+ 'Check tradingMode from fetch_balance — the source of truth for paper vs live. ' +
59
+ 'Batch independent tool calls in parallel. Trading tools only: no subagents, no web browsing.';
36
60
  /**
37
61
  * Whether an existing cron job counts as "the heartbeat already exists".
38
62
  * Union of the two matchers already in the codebase: the old setup path
@@ -89,8 +113,8 @@ export async function ensureHeartbeatCron(opts) {
89
113
  // cron.add on 2026.7.1-2). No toolsAllow: absent = all-tools-allowed,
90
114
  // which is future-proof — an explicit allowlist freezes at creation and
91
115
  // silently excludes every plugin tool shipped later. Beat tool
92
- // discipline (no subagents/web/media during heartbeats) lives in
93
- // SKILL.md, which updates on every bridge start.
116
+ // discipline (no subagents/web during heartbeats) is stated in
117
+ // HEARTBEAT_MESSAGE itself.
94
118
  payload: {
95
119
  kind: 'agentTurn',
96
120
  message: HEARTBEAT_MESSAGE,
@@ -114,6 +114,11 @@ export declare class Poller {
114
114
  * - Errors are logged and swallowed (never crash the provider)
115
115
  */
116
116
  private guardedExec;
117
+ /** Arm the circuit-breaker for a Binance-bound call made OUTSIDE
118
+ * guardedExec (e.g. the gateway's paper mark-to-market ticker fan-out).
119
+ * Those calls used to swallow 418/429s entirely — exactly the
120
+ * ban-extending failure mode the gate exists to prevent. */
121
+ noteExternalRateLimit(label: string, msg: string): void;
117
122
  /** Inspect a failed poll's error for Binance rate-limit signals and arm
118
123
  * the process-wide circuit-breaker accordingly.
119
124
  *
@@ -266,6 +266,15 @@ export class Poller {
266
266
  this.inflight.delete(label);
267
267
  }
268
268
  }
269
+ /** Arm the circuit-breaker for a Binance-bound call made OUTSIDE
270
+ * guardedExec (e.g. the gateway's paper mark-to-market ticker fan-out).
271
+ * Those calls used to swallow 418/429s entirely — exactly the
272
+ * ban-extending failure mode the gate exists to prevent. */
273
+ noteExternalRateLimit(label, msg) {
274
+ if (isRateLimitShapedError(msg)) {
275
+ this.applyRateLimitBackoff(label, msg);
276
+ }
277
+ }
269
278
  /** Inspect a failed poll's error for Binance rate-limit signals and arm
270
279
  * the process-wide circuit-breaker accordingly.
271
280
  *
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();
@@ -1,5 +1,6 @@
1
1
  import type { EmergencyAction, ReconciliationSnapshot, TickerData, CandleData, OrderUpdatePayload, AgentStateData, RiskUpdatePayload, ChatMessage, MarketStructureData, CryptoMetricsData, VolumeAnalysisData, TradeJournalEntry, RegimeData, SignalData, MissionData, AnalyticsData, ShadowComparisonData, TradingModeData, DecisionTraceData } from './types.js';
2
2
  import type { TradingMode } from '@reefclaw/shared';
3
+ import type { ConnectorUpdateOutcome } from './providers/connector-update.js';
3
4
  /** Per-venue credential shapes for the operator set/test RPCs. Binance is an
4
5
  * HMAC key pair; Hyperliquid is a MASTER account address (public — queries)
5
6
  * plus an approved AGENT wallet private key (signs only, cannot withdraw).
@@ -48,6 +49,9 @@ export interface HlSubmitApprovalOutcome {
48
49
  chain?: string;
49
50
  agentAddress?: string;
50
51
  }
52
+ /** Progress of an operator-triggered connector update. Re-exported from the
53
+ * driver so callers get the status union without importing the PTY module. */
54
+ export type { ConnectorUpdateOutcome, ConnectorUpdateStatus, } from './providers/connector-update.js';
51
55
  /** Result of hl_agent_wallet_status — approval polling for the guided flow. */
52
56
  export interface HlAgentWalletStatusOutcome {
53
57
  ok: boolean;
@@ -100,6 +104,8 @@ export interface ProviderEvents {
100
104
  id: string;
101
105
  toolName: string;
102
106
  }) => void;
107
+ /** Progress of an operator-triggered connector update (PTY output + status). */
108
+ connectorUpdate: (data: ConnectorUpdateOutcome) => void;
103
109
  marketStructure: (data: MarketStructureData) => void;
104
110
  cryptoMetrics: (data: CryptoMetricsData) => void;
105
111
  volumeAnalysis: (data: VolumeAnalysisData) => void;
@@ -217,6 +223,15 @@ export interface OpenClawProvider {
217
223
  v: number;
218
224
  };
219
225
  }): Promise<HlSubmitApprovalOutcome>;
226
+ /** Operator-only: update the ReefClaw connector on this box to the latest
227
+ * published version, driven over the gateway's terminal.* PTY methods.
228
+ *
229
+ * There is no command parameter, by design — see the security note at the
230
+ * top of providers/connector-update.ts. The only input is the operator's
231
+ * acknowledgement that the resulting restart may interrupt an open book. */
232
+ updateConnector?(args: {
233
+ acknowledgeOpenPositions?: boolean;
234
+ }): Promise<ConnectorUpdateOutcome>;
220
235
  /** Operator-only: remove stored credentials from the plugin config and
221
236
  * de-escalate to PAPER mode if currently running in a live mode. */
222
237
  clearExchangeCredentials?(): Promise<{
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The ONE command this feature can ever run.
3
+ *
4
+ * `-y` so npx never blocks on its install prompt; `@latest` because this is an
5
+ * update. No user input, no interpolation, no exceptions.
6
+ */
7
+ export declare const CONNECTOR_UPDATE_COMMAND = "npx -y @reefclaw/connect@latest";
8
+ /** Marker printed after the command so we can detect completion + exit code. */
9
+ export declare const DONE_SENTINEL = "__RC_CONNECTOR_UPDATE_DONE__";
10
+ /**
11
+ * The exact line written to the PTY. Built once, from constants only.
12
+ * `$?` is expanded by the remote shell, not by us.
13
+ */
14
+ export declare function buildUpdateCommandLine(): string;
15
+ /** Minimal RPC surface this module needs (injected — keeps it unit-testable). */
16
+ export type TerminalRpc = (method: string, params: Record<string, unknown>) => Promise<unknown>;
17
+ export type ConnectorUpdateStatus =
18
+ /** Command written to the PTY; output is streaming. */
19
+ 'started'
20
+ /** DONE sentinel observed — we have a real exit code. */
21
+ | 'completed'
22
+ /** Transport died after the command started = the gateway restart landed. */
23
+ | 'restarting'
24
+ /** Refused before anything ran (terminal disabled / sandboxed / open positions). */
25
+ | 'blocked'
26
+ /** Something genuinely went wrong. */
27
+ | 'failed';
28
+ export interface ConnectorUpdateOutcome {
29
+ ok: boolean;
30
+ status: ConnectorUpdateStatus;
31
+ message: string;
32
+ sessionId?: string;
33
+ exitCode?: number | null;
34
+ /** Accumulated screen text, for the dashboard's output pane. */
35
+ output?: string;
36
+ /** True when the caller must re-submit with acknowledgeOpenPositions. */
37
+ requiresPositionAck?: boolean;
38
+ }
39
+ /**
40
+ * Map a gateway error from `terminal.open` onto an operator-readable outcome.
41
+ *
42
+ * The two refusals we know the gateway can produce are worth distinct copy —
43
+ * both are configuration facts on the trader's box that no retry will fix:
44
+ * - "terminal is not available" / "terminal is disabled"
45
+ * - agent runs in a sandbox → in-sandbox terminals are unsupported
46
+ */
47
+ export declare function describeOpenFailure(err: unknown): ConnectorUpdateOutcome;
48
+ /**
49
+ * Once the command is running, losing the transport is EXPECTED — the installer
50
+ * restarts the gateway, which is the process hosting both the PTY and our own
51
+ * connection. Treating that as a failure would report a successful update as
52
+ * broken, so the distinction is explicit and tested.
53
+ */
54
+ export declare function classifyTransportLoss(started: boolean): ConnectorUpdateStatus;
55
+ /** Extract the exit code that follows the DONE sentinel, if it has appeared. */
56
+ export declare function parseDoneSentinel(screen: string): {
57
+ done: boolean;
58
+ exitCode: number | null;
59
+ };
60
+ /**
61
+ * The sentinel is echoed by the shell as part of the command line BEFORE the
62
+ * command runs, so a naive `includes()` reports "done" immediately. Strip the
63
+ * echoed command line first: completion is only credible after the printf has
64
+ * actually executed, which is the LAST occurrence and is followed by a digit.
65
+ */
66
+ export declare function isCredibleCompletion(screen: string): boolean;
67
+ export interface StartOptions {
68
+ /** Open positions the operator has been warned about and accepted. */
69
+ acknowledgeOpenPositions?: boolean;
70
+ /** Count of currently-open positions, as the skill sees them. */
71
+ openPositionCount: number;
72
+ }
73
+ /**
74
+ * Guard the restart hazard. The installer restarts the gateway; with positions
75
+ * open that means the agent stops managing them for the duration (exchange-side
76
+ * brackets still protect the book — they are enforced by the venue, not by us —
77
+ * but there is a known startup window where protective closes are refused).
78
+ * So: never silently update a live book. Demand an explicit acknowledgement.
79
+ */
80
+ export declare function checkPositionGuard(opts: StartOptions): ConnectorUpdateOutcome | null;
81
+ /**
82
+ * Open a PTY and write the update command to it. Returns as soon as the command
83
+ * is running — the caller polls `readTerminalText` for output.
84
+ */
85
+ export declare function startConnectorUpdate(rpc: TerminalRpc, opts: StartOptions): Promise<ConnectorUpdateOutcome>;
86
+ /** Read the current screen contents. Returns null when the session is gone. */
87
+ export declare function readTerminalText(rpc: TerminalRpc, sessionId: string): Promise<string | null>;
88
+ /** Best-effort PTY cleanup. Never throws — cleanup failure must not mask an outcome. */
89
+ export declare function closeTerminal(rpc: TerminalRpc, sessionId: string): Promise<void>;