@reefclaw/connect 0.1.30 → 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.
Files changed (70) hide show
  1. package/assets/bridge/bridge.js +72 -5
  2. package/assets/bridge/connector.d.ts +3 -1
  3. package/assets/bridge/connector.js +51 -4
  4. package/assets/bridge/gateway/heartbeat-cron.js +31 -7
  5. package/assets/bridge/gateway/poller.d.ts +5 -0
  6. package/assets/bridge/gateway/poller.js +9 -0
  7. package/assets/bridge/index.js +4 -0
  8. package/assets/bridge/provider.d.ts +15 -0
  9. package/assets/bridge/providers/connector-update.d.ts +89 -0
  10. package/assets/bridge/providers/connector-update.js +212 -0
  11. package/assets/bridge/providers/emergency-commands.d.ts +36 -0
  12. package/assets/bridge/providers/emergency-commands.js +91 -0
  13. package/assets/bridge/providers/gateway.d.ts +26 -1
  14. package/assets/bridge/providers/gateway.js +159 -8
  15. package/assets/bridge/providers/mock.js +1 -0
  16. package/assets/bridge/types.d.ts +5 -1
  17. package/assets/bridge/types.js +5 -0
  18. package/assets/bridge/utils/instance-id.d.ts +3 -0
  19. package/assets/bridge/utils/instance-id.js +48 -0
  20. package/assets/plugin/ccxt/binance-private.js +2 -1
  21. package/assets/plugin/ccxt/binance-public.js +6 -1
  22. package/assets/plugin/config/agent-config-client.d.ts +5 -2
  23. package/assets/plugin/config/agent-config-client.js +13 -0
  24. package/assets/plugin/config/agent-config-poller.js +5 -1
  25. package/assets/plugin/config/gate-store.d.ts +9 -0
  26. package/assets/plugin/config/gate-store.js +17 -2
  27. package/assets/plugin/config/plugin-config-io.js +24 -2
  28. package/assets/plugin/http/keepalive-fetch.d.ts +5 -0
  29. package/assets/plugin/http/keepalive-fetch.js +50 -0
  30. package/assets/plugin/index.js +53 -6
  31. package/assets/plugin/ingest/position-auto-capture.js +14 -2
  32. package/assets/plugin/ingest/position-decisions-client.d.ts +6 -0
  33. package/assets/plugin/ingest/position-decisions-client.js +27 -9
  34. package/assets/plugin/live/approval-lifecycle.d.ts +10 -0
  35. package/assets/plugin/live/approval-lifecycle.js +16 -2
  36. package/assets/plugin/live/microstructure-assembler.js +11 -2
  37. package/assets/plugin/live/proposal-decision-listener.d.ts +21 -0
  38. package/assets/plugin/live/proposal-decision-listener.js +39 -0
  39. package/assets/plugin/live/proposal-manager.d.ts +12 -0
  40. package/assets/plugin/live/proposal-manager.js +47 -0
  41. package/assets/plugin/live/stop-watcher.d.ts +16 -1
  42. package/assets/plugin/live/stop-watcher.js +48 -8
  43. package/assets/plugin/onboarding/runtime.js +4 -0
  44. package/assets/plugin/openclaw.plugin.json +1 -1
  45. package/assets/plugin/persistence/state-manager.d.ts +7 -0
  46. package/assets/plugin/persistence/state-manager.js +28 -1
  47. package/assets/plugin/simulator/exchange-simulator.d.ts +27 -1
  48. package/assets/plugin/simulator/exchange-simulator.js +98 -38
  49. package/assets/plugin/tools/audit-bracket-protection.js +11 -7
  50. package/assets/plugin/tools/close-position.js +10 -1
  51. package/assets/plugin/tools/create-order.js +49 -7
  52. package/assets/plugin/tools/get-funding-context.js +6 -1
  53. package/assets/plugin/tools/get-liquidation-levels.js +5 -1
  54. package/assets/plugin/tools/get-liquidation-pulse.js +7 -1
  55. package/assets/plugin/tools/get-market-intel.js +2 -1
  56. package/assets/plugin/tools/get-relevant-learnings.js +20 -1
  57. package/assets/plugin/tools/get-resting-liquidity.js +6 -1
  58. package/assets/plugin/tools/get-wave9-status.js +17 -0
  59. package/assets/plugin/tools/intel-api.d.ts +9 -0
  60. package/assets/plugin/tools/intel-api.js +32 -1
  61. package/assets/plugin/tools/record-position-reviews.js +2 -2
  62. package/assets/plugin/tools/scan-pairs.js +20 -11
  63. package/assets/plugin/types.d.ts +7 -0
  64. package/assets/plugin/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  65. package/assets/plugin/venues/hyperliquid/hl-live-adapter.js +101 -13
  66. package/assets/shared/readiness.js +5 -1
  67. package/dist/cli.js +27 -6
  68. package/dist/openclaw.js +1 -1
  69. package/dist/validate.js +46 -9
  70. package/package.json +33 -32
@@ -3,7 +3,7 @@ export type { Channel, RequestFrame, ResponseFrame, EventFrame, Frame, Emergency
3
3
  export { VALID_CHANNELS, VALID_EMERGENCY_ACTIONS } from '@reefclaw/shared';
4
4
  type EventFrame = _EventFrame;
5
5
  /** Methods the skill accepts from the relay (browser → skill) */
6
- export declare const ALLOWED_METHODS: readonly ["emergency.kill", "emergency.flatten", "emergency.pause", "emergency.resume", "reconcile", "chat.send", "skill.update", "close_position", "set_trading_mode", "set_exchange_credentials", "test_exchange_credentials", "clear_exchange_credentials", "hl_provision_agent_wallet", "hl_agent_wallet_status", "hl_submit_agent_approval", "get_bracket_config", "set_bracket_requirement"];
6
+ export declare const ALLOWED_METHODS: readonly ["emergency.kill", "emergency.flatten", "emergency.pause", "emergency.resume", "reconcile", "chat.send", "skill.update", "close_position", "set_trading_mode", "set_exchange_credentials", "test_exchange_credentials", "clear_exchange_credentials", "hl_provision_agent_wallet", "hl_agent_wallet_status", "hl_submit_agent_approval", "get_bracket_config", "set_bracket_requirement", "connector.update"];
7
7
  /** Subset of ALLOWED_METHODS that require operator.write scope. The bridge
8
8
  * enforces this before dispatching — a session without the scope gets a
9
9
  * 403 error. PR2 ships the scope as session-wide (inherited from the Clerk
@@ -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;
@@ -25,6 +25,10 @@ export const ALLOWED_METHODS = [
25
25
  // Bracket-orders config (Phase 3.5b) — operator.write-gated.
26
26
  'get_bracket_config',
27
27
  'set_bracket_requirement',
28
+ // Operator-triggered connector update. Runs a FIXED command on the box over
29
+ // the gateway PTY — carries no command/version/args field by design (see the
30
+ // security note in providers/connector-update.ts). operator.write-gated.
31
+ 'connector.update',
28
32
  ];
29
33
  /** Subset of ALLOWED_METHODS that require operator.write scope. The bridge
30
34
  * enforces this before dispatching — a session without the scope gets a
@@ -42,6 +46,7 @@ export const OPERATOR_WRITE_METHODS = new Set([
42
46
  'hl_submit_agent_approval',
43
47
  'get_bracket_config',
44
48
  'set_bracket_requirement',
49
+ 'connector.update',
45
50
  'emergency.kill',
46
51
  'emergency.flatten',
47
52
  'emergency.pause',
@@ -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
+ }
@@ -813,7 +813,8 @@ export class BinancePrivateApi {
813
813
  const raw = await this.exchange.fetchTicker(symbol);
814
814
  noteSuccess();
815
815
  return {
816
- symbol: raw.symbol,
816
+ // Echo the REQUESTED symbol — see BinancePublicApi.fetchTicker.
817
+ symbol,
817
818
  last: raw.last ?? 0,
818
819
  bid: raw.bid ?? 0,
819
820
  ask: raw.ask ?? 0,
@@ -86,7 +86,12 @@ export class BinancePublicApi {
86
86
  const raw = await this.exchange.fetchTicker(symbol);
87
87
  noteSuccess();
88
88
  return {
89
- symbol: raw.symbol,
89
+ // Echo the REQUESTED symbol, not ccxt's unified form. ccxt rewrites
90
+ // 'ETH/USDT' → 'ETH/USDT:USDT' on USDM futures; returning that made
91
+ // callers key per-symbol caches under a name the caller never asked
92
+ // for (see the simulator's tickerKey note). Same invariant that
93
+ // intel-public.ts fetchTicker already documents.
94
+ symbol,
90
95
  last: raw.last ?? 0,
91
96
  bid: raw.bid ?? 0,
92
97
  ask: raw.ask ?? 0,
@@ -4,11 +4,14 @@
4
4
  * never whole-payload rejection. Gates are added one at a time
5
5
  * (TOOL_DISTRIBUTION_ARCHITECTURE.md §11 step 3): `exitGate` (slice 2) →
6
6
  * `positionReviewMode` (slice 3, the Position Decision Journal
7
- * heartbeat-mandate + superset gate, file key `positionReview.mode`). Both
8
- * ride the same four-stage `off shadow → observe → enforce` ladder. */
7
+ * heartbeat-mandate + superset gate, file key `positionReview.mode`)
8
+ * `approvalMode` (slice 4, per-trade operator approval, file key
9
+ * `approval.mode`). The first two ride the same four-stage
10
+ * `off → shadow → observe → enforce` ladder; approvalMode has its own. */
9
11
  export interface AgentGates {
10
12
  exitGate?: 'off' | 'shadow' | 'observe' | 'enforce';
11
13
  positionReviewMode?: 'off' | 'shadow' | 'observe' | 'enforce';
14
+ approvalMode?: 'off' | 'per_trade';
12
15
  }
13
16
  /** Server-resolved entitlement verdict (webapp lib/entitlements.ts, computed
14
17
  * from the users row and delivered on the config channel). The plugin NEVER
@@ -23,6 +23,15 @@ const TAG = 'agent-config';
23
23
  * because exitGate + positionReviewMode use identical values; a future gate
24
24
  * with a different enum gets its own constant. */
25
25
  const MODE_LADDER_VALUES = new Set(['off', 'shadow', 'observe', 'enforce']);
26
+ /** approvalMode's own enum — deliberately NOT the four-stage ladder.
27
+ *
28
+ * ★ `shadow` is absent on purpose. Shadow proposal telemetry is driven by the
29
+ * APPROVAL_SHADOW_MODE systemd env flag, which is boot-snapshotted and local
30
+ * to the box; it is not a central value. Accepting 'shadow' here would let a
31
+ * central push silently enable telemetry writes the operator never configured,
32
+ * and would collide with the env flag's precedence. Central can express
33
+ * exactly the two states that change trading behaviour. */
34
+ const APPROVAL_MODE_VALUES = new Set(['off', 'per_trade']);
26
35
  const ENTITLEMENT_STATES = new Set([
27
36
  'active',
28
37
  'trialing',
@@ -112,6 +121,10 @@ function validateGates(raw) {
112
121
  if (typeof positionReviewMode === 'string' && MODE_LADDER_VALUES.has(positionReviewMode)) {
113
122
  gates.positionReviewMode = positionReviewMode;
114
123
  }
124
+ const approvalMode = obj.approvalMode;
125
+ if (typeof approvalMode === 'string' && APPROVAL_MODE_VALUES.has(approvalMode)) {
126
+ gates.approvalMode = approvalMode;
127
+ }
115
128
  return gates;
116
129
  }
117
130
  /** Version-monotonic acceptance (basic rollback/replay protection): a fetched
@@ -65,7 +65,11 @@ export function startAgentConfigPoller(opts) {
65
65
  });
66
66
  if (fetched) {
67
67
  loggedFetchFailure = false;
68
- if (applyIfAcceptable(fetched, 'network')) {
68
+ // Only touch the cache file when the config actually changed —
69
+ // isAcceptableVersion accepts equal versions, so this used to do a
70
+ // blocking write+rename every 60s poll forever.
71
+ const prevJson = JSON.stringify(current);
72
+ if (applyIfAcceptable(fetched, 'network') && JSON.stringify(fetched) !== prevJson) {
69
73
  writeCachedConfig(fetched, opts.cachePath);
70
74
  }
71
75
  return;
@@ -9,6 +9,15 @@ declare class GateStore {
9
9
  /** The central positionReview.mode, or null when central has no value (or the
10
10
  * kill-switch is on) — null tells the reader to fall back to the file. */
11
11
  getPositionReviewMode(): AgentGates['positionReviewMode'] | null;
12
+ /** The central approval.mode, or null when central has no value (or the
13
+ * kill-switch is on) — null tells the reader to fall back to the file.
14
+ *
15
+ * ★ Central can only ever say 'off' or 'per_trade'. It cannot enable shadow
16
+ * telemetry (that's the local APPROVAL_SHADOW_MODE env flag) and it cannot
17
+ * weaken the hardcoded safety floor — per_trade only ADDS a gate, and 'off'
18
+ * is the pre-existing autonomous behaviour, so neither value can leave a
19
+ * position unprotected. */
20
+ getApprovalMode(): AgentGates['approvalMode'] | null;
12
21
  /** Test-only. */
13
22
  __reset(): void;
14
23
  }
@@ -30,11 +30,13 @@ class GateStore {
30
30
  apply(gates) {
31
31
  const next = gates ?? {};
32
32
  const changed = next.exitGate !== this.gates.exitGate ||
33
- next.positionReviewMode !== this.gates.positionReviewMode;
33
+ next.positionReviewMode !== this.gates.positionReviewMode ||
34
+ next.approvalMode !== this.gates.approvalMode;
34
35
  this.gates = { ...next };
35
36
  if (changed) {
36
37
  logger.info(TAG, `applied central gates: exitGate=${next.exitGate ?? UNSET} ` +
37
- `positionReviewMode=${next.positionReviewMode ?? UNSET}`);
38
+ `positionReviewMode=${next.positionReviewMode ?? UNSET} ` +
39
+ `approvalMode=${next.approvalMode ?? UNSET}`);
38
40
  }
39
41
  }
40
42
  /** The central exitGate mode, or null when central has no value (or the
@@ -51,6 +53,19 @@ class GateStore {
51
53
  return null;
52
54
  return this.gates.positionReviewMode ?? null;
53
55
  }
56
+ /** The central approval.mode, or null when central has no value (or the
57
+ * kill-switch is on) — null tells the reader to fall back to the file.
58
+ *
59
+ * ★ Central can only ever say 'off' or 'per_trade'. It cannot enable shadow
60
+ * telemetry (that's the local APPROVAL_SHADOW_MODE env flag) and it cannot
61
+ * weaken the hardcoded safety floor — per_trade only ADDS a gate, and 'off'
62
+ * is the pre-existing autonomous behaviour, so neither value can leave a
63
+ * position unprotected. */
64
+ getApprovalMode() {
65
+ if (!centralGatesEnabled())
66
+ return null;
67
+ return this.gates.approvalMode ?? null;
68
+ }
54
69
  /** Test-only. */
55
70
  __reset() {
56
71
  this.gates = {};
@@ -10,7 +10,7 @@
10
10
  // operator-only tool gated on dashboard provenance (verifyOperatorProvenance,
11
11
  // audit F12) — the agent cannot reach these writes conversationally. The file
12
12
  // is the plugin's OWN config store; nothing here touches OpenClaw's config.
13
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { logger } from '../logger.js';
@@ -82,17 +82,39 @@ export function loadMicroLiveConfig(path) {
82
82
  catch { /* best-effort — adapter default applies */ }
83
83
  return undefined;
84
84
  }
85
+ // mtime(ns)+size memo: several hot paths re-read this file per tool call
86
+ // (approval mode, bracket mode/requirements, exit gate, review mode, the
87
+ // microstructure flag × N symbols on the review path). A write always bumps
88
+ // mtime, so the documented per-call hot-reload semantics are preserved
89
+ // exactly — an unchanged file just costs one stat instead of read+parse.
90
+ // structuredClone on both sides keeps today's fresh-object-per-call contract.
91
+ const readMemo = new Map();
85
92
  /** Read the config file. Returns `{}` if the file doesn't exist.
86
93
  * Throws if the file exists but is unreadable or not valid JSON — callers
87
94
  * should treat that as an abort signal, not silently overwrite. */
88
95
  export function readPluginConfig(path = defaultConfigPath()) {
89
- if (!existsSync(path))
96
+ if (!existsSync(path)) {
97
+ readMemo.delete(path);
90
98
  return {};
99
+ }
100
+ let stat;
101
+ try {
102
+ const s = statSync(path, { bigint: true });
103
+ stat = { mtimeNs: s.mtimeNs, size: s.size };
104
+ const hit = readMemo.get(path);
105
+ if (hit && hit.mtimeNs === stat.mtimeNs && hit.size === stat.size) {
106
+ return structuredClone(hit.parsed);
107
+ }
108
+ }
109
+ catch { /* stat raced a delete — fall through to the plain read */ }
91
110
  const raw = readFileSync(path, 'utf-8');
92
111
  const parsed = JSON.parse(raw);
93
112
  if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
94
113
  throw new Error(`plugin-config.json root is not an object`);
95
114
  }
115
+ if (stat) {
116
+ readMemo.set(path, { ...stat, parsed: structuredClone(parsed) });
117
+ }
96
118
  return parsed;
97
119
  }
98
120
  /** Apply a patch on top of the existing file and write atomically.
@@ -0,0 +1,5 @@
1
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
2
+ /** Drop-in fetch with connection keep-alive; falls back to global fetch when
3
+ * undici is unavailable, and defers to globalThis.fetch whenever it has been
4
+ * replaced (mocks/instrumentation). */
5
+ export declare function keepAliveFetch(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,50 @@
1
+ // Keep-alive HTTP for the plugin's intel/webapp clients.
2
+ //
3
+ // Node's built-in fetch closes idle sockets after undici's 4s default, and the
4
+ // gap between two agent tool calls is LLM think-time (seconds to tens of
5
+ // seconds) — so every intel/webapp call was paying a fresh TCP+TLS handshake
6
+ // (~2 RTTs) before any server work started. A dedicated undici Agent with a
7
+ // 60s idle timeout holds the connection across those gaps.
8
+ //
9
+ // undici loads via createRequire (same rule as CCXT — see
10
+ // docs/CLAUDE/plugin-integration.md) and the whole module FAILS OPEN to the
11
+ // global fetch: dist-only overlay deploys land on boxes whose node_modules
12
+ // predate this dependency, and a missing package must degrade to today's
13
+ // behaviour, never crash the connector.
14
+ import { createRequire } from 'node:module';
15
+ // Captured at module load. When someone REPLACES globalThis.fetch later
16
+ // (vitest fetch mocks, tracing wrappers), keepAliveFetch honors the
17
+ // replacement instead of undici — otherwise every fetch-stubbing test (and
18
+ // any legitimate instrumentation) would be silently bypassed onto the real
19
+ // network.
20
+ const nativeFetch = globalThis.fetch;
21
+ let cached;
22
+ function build() {
23
+ try {
24
+ const req = createRequire(import.meta.url);
25
+ const undici = req('undici');
26
+ const dispatcher = new undici.Agent({
27
+ keepAliveTimeout: 60_000,
28
+ keepAliveMaxTimeout: 300_000,
29
+ connections: 16,
30
+ });
31
+ // undici's own fetch + Agent are used together: passing an npm-undici
32
+ // dispatcher to Node's built-in fetch can fail an instanceof check against
33
+ // the internal undici copy.
34
+ return (url, init) => undici.fetch(url, { ...init, dispatcher });
35
+ }
36
+ catch {
37
+ return (url, init) => fetch(url, init);
38
+ }
39
+ }
40
+ /** Drop-in fetch with connection keep-alive; falls back to global fetch when
41
+ * undici is unavailable, and defers to globalThis.fetch whenever it has been
42
+ * replaced (mocks/instrumentation). */
43
+ export function keepAliveFetch(url, init) {
44
+ if (globalThis.fetch !== nativeFetch) {
45
+ return globalThis.fetch(url, init);
46
+ }
47
+ if (!cached)
48
+ cached = build();
49
+ return cached(url, init);
50
+ }
@@ -989,7 +989,10 @@ const paperTradingPlugin = {
989
989
  // gateway process must reload it before serving HTTP API calls.
990
990
  const reloadState = () => {
991
991
  try {
992
- const fresh = stateManager.loadSync();
992
+ // Skips the full read+parse (and replaceState) when the on-disk file
993
+ // hasn't changed since the last load — this runs on every paper-mode
994
+ // tool call and the file grows with trade history.
995
+ const fresh = stateManager.loadSyncIfChanged();
993
996
  if (fresh) {
994
997
  simulator.replaceState(fresh);
995
998
  }
@@ -1293,11 +1296,32 @@ const paperTradingPlugin = {
1293
1296
  // rows while collecting real ones). See docs/APPROVAL_MODE_DESIGN.md §12.
1294
1297
  const approvalShadowEnabled = (process.env.APPROVAL_SHADOW_MODE ?? '').trim() === '1';
1295
1298
  const resolveApprovalMode = () => {
1296
- try {
1297
- if (readPluginConfig().approval?.mode === 'per_trade')
1298
- return 'per_trade';
1299
+ // Precedence (config-service slice 4, mirrors loadExitGateMode /
1300
+ // loadPositionReviewMode):
1301
+ //
1302
+ // central (agent_config.gates.approvalMode) → plugin-config.json →
1303
+ // APPROVAL_SHADOW_MODE env → 'off'
1304
+ //
1305
+ // Central is enum-validated plugin-side (agent-config-client
1306
+ // validateGates) and can only ever say 'off' | 'per_trade' — it cannot
1307
+ // enable shadow telemetry and cannot weaken the safety floor. Kill-switch
1308
+ // RC_CENTRAL_GATES=off makes the store report null and the file rules
1309
+ // again. Read per call, so a dashboard flip applies within one poll
1310
+ // (~60 s) with no restart.
1311
+ const central = gateStore.getApprovalMode();
1312
+ if (central === 'per_trade')
1313
+ return 'per_trade';
1314
+ if (central !== 'off') {
1315
+ // No central value — fall back to the local file.
1316
+ try {
1317
+ if (readPluginConfig().approval?.mode === 'per_trade')
1318
+ return 'per_trade';
1319
+ }
1320
+ catch { /* config unreadable — env-only fallback below */ }
1299
1321
  }
1300
- catch { /* config unreadable env-only fallback below */ }
1322
+ // Central 'off' still permits local shadow telemetry: shadow does not
1323
+ // change trading behaviour (the order fires either way), it only writes
1324
+ // an extra row, and the env flag is the operator's own local choice.
1301
1325
  return approvalShadowEnabled ? 'shadow' : 'off';
1302
1326
  };
1303
1327
  // Proposal manager + listener credentials: built whenever ingest
@@ -1327,8 +1351,18 @@ const paperTradingPlugin = {
1327
1351
  logger.info(TAG, `Approval wiring active (mode=${bootApprovalMode}) → ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1328
1352
  }
1329
1353
  }
1354
+ else if (bootApprovalMode === 'per_trade') {
1355
+ // FAIL-CLOSED state: create_order will REFUSE every new entry until the
1356
+ // credentials are restored or approval.mode is set back to off. Loud at
1357
+ // boot AND at each refusal (create-order.ts) — a boot-only signal is how
1358
+ // this used to go unnoticed while orders fired without the gate.
1359
+ logger.error(TAG, `approval mode=per_trade but ingest token / REEFCLAW_USER_ID missing — proposals CANNOT ` +
1360
+ `reach the operator, so create_order will REFUSE every new entry (fail-closed). ` +
1361
+ `Restore the plugin connectionToken/WEBAPP_INGEST_TOKEN + REEFCLAW_USER_ID, or set ` +
1362
+ `approval.mode=off. Exits, stops, brackets and operator controls are unaffected.`);
1363
+ }
1330
1364
  else if (bootApprovalMode !== 'off') {
1331
- logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing — proposal path disabled (orders fire directly)`);
1365
+ logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing — shadow telemetry disabled (orders fire directly, as in off mode)`);
1332
1366
  }
1333
1367
  }
1334
1368
  // ---- Create exchange adapter based on trading mode ----
@@ -1384,6 +1418,11 @@ const paperTradingPlugin = {
1384
1418
  // F26: same wiring object as the Binance arm — the SIGTERM
1385
1419
  // drain covers both venues because it drains this client.
1386
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,
1387
1426
  },
1388
1427
  }
1389
1428
  : {
@@ -1887,6 +1926,14 @@ const paperTradingPlugin = {
1887
1926
  pollIntervalMs: approvalCfg?.pollIntervalMs ?? 3_000,
1888
1927
  });
1889
1928
  },
1929
+ // The approval path just went away (mode flipped off, or live→PAPER).
1930
+ // Anything still pending is now un-fireable but still shows an Approve
1931
+ // button — cancel it rather than leave the operator a dead control.
1932
+ onApprovalPathDisabled: async () => {
1933
+ if (!proposalManagerCtx)
1934
+ return;
1935
+ await proposalManagerCtx.manager.cancelAll(proposalManagerCtx.userId, 'mode_disabled');
1936
+ },
1890
1937
  });
1891
1938
  runtime.setOnAdapterSwapped((a) => { void approvalLifecycle.onAdapterSwapped(a); });
1892
1939
  // Boot application — the same path every later swap takes.
@@ -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,
@@ -322,6 +322,12 @@ export declare class PositionDecisionsClient {
322
322
  private fireAndForget;
323
323
  private run;
324
324
  private runReturning;
325
+ /** Decision-path GET budget. Every read caller degrades to empty/null on
326
+ * failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
327
+ * worst case) of the agent's turn to reach an optional result is pure
328
+ * heartbeat latency. Background reads (reconcile sweep) override per call. */
329
+ private static readonly READ_MAX_ATTEMPTS;
330
+ private static readonly READ_DEADLINE_MS;
325
331
  private runGetReturning;
326
332
  private sleepBackoff;
327
333
  }
@@ -12,6 +12,7 @@
12
12
  // - postEntry/Review/Close are fire-and-forget (don't block the WS hot path).
13
13
  //
14
14
  // All four routes accept the same auth: Bearer + X-User-Id headers.
15
+ import { keepAliveFetch } from '../http/keepalive-fetch.js';
15
16
  import { logger, formatError } from '../logger.js';
16
17
  const TAG = 'position-decisions-client';
17
18
  export class PositionDecisionsClient {
@@ -25,7 +26,7 @@ export class PositionDecisionsClient {
25
26
  this.opts = {
26
27
  baseUrl: options.baseUrl.replace(/\/+$/, ''),
27
28
  ingestToken: options.ingestToken,
28
- fetchImpl: options.fetchImpl ?? fetch,
29
+ fetchImpl: options.fetchImpl ?? keepAliveFetch,
29
30
  requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
30
31
  maxAttempts: options.maxAttempts ?? 4,
31
32
  baseBackoffMs: options.baseBackoffMs ?? 250,
@@ -65,7 +66,12 @@ export class PositionDecisionsClient {
65
66
  qs.set('mode', mode);
66
67
  if (exchange)
67
68
  qs.set('exchange', exchange);
68
- return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}`);
69
+ return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}`, {
70
+ // Reconcile-sweep read, NOT on the agent decision path — keep the
71
+ // write-grade retry budget: a null here skips a whole reconcile pass.
72
+ maxAttempts: this.opts.maxAttempts,
73
+ deadlineMs: Number.POSITIVE_INFINITY,
74
+ });
69
75
  }
70
76
  /** Read endpoint for the Phase 1 self-reflection feature. Awaited.
71
77
  * Returns null on terminal/retry-exhausted failure (caller logs + degrades). */
@@ -246,12 +252,24 @@ export class PositionDecisionsClient {
246
252
  logger.error(TAG, `POST ${url} dropped after ${this.opts.maxAttempts} attempts. userId=${userId}.`);
247
253
  return null;
248
254
  }
249
- async runGetReturning(userId, path) {
255
+ /** Decision-path GET budget. Every read caller degrades to empty/null on
256
+ * failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
257
+ * worst case) of the agent's turn to reach an optional result is pure
258
+ * heartbeat latency. Background reads (reconcile sweep) override per call. */
259
+ static READ_MAX_ATTEMPTS = 2;
260
+ static READ_DEADLINE_MS = 8_000;
261
+ async runGetReturning(userId, path, budget) {
250
262
  const url = `${this.opts.baseUrl}${path}`;
251
- for (let attempt = 1; attempt <= this.opts.maxAttempts; attempt++) {
263
+ const maxAttempts = budget?.maxAttempts ??
264
+ Math.min(this.opts.maxAttempts, PositionDecisionsClient.READ_MAX_ATTEMPTS);
265
+ const deadlineAt = Date.now() + (budget?.deadlineMs ?? PositionDecisionsClient.READ_DEADLINE_MS);
266
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
267
+ const remainingMs = deadlineAt - Date.now();
268
+ if (remainingMs <= 0)
269
+ break;
252
270
  try {
253
271
  const ac = new AbortController();
254
- const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
272
+ const tid = setTimeout(() => ac.abort(), Math.min(this.opts.requestTimeoutMs, remainingMs));
255
273
  let res;
256
274
  try {
257
275
  res = await this.opts.fetchImpl(url, {
@@ -285,16 +303,16 @@ export class PositionDecisionsClient {
285
303
  logger.warn(TAG, `GET ${url} → ${res.status} (terminal, dropped). userId=${userId} body=${errBody.slice(0, 200)}`);
286
304
  return null;
287
305
  }
288
- logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${this.opts.maxAttempts}).`);
306
+ logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${maxAttempts}).`);
289
307
  }
290
308
  catch (err) {
291
- logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${this.opts.maxAttempts}): ${formatError(err)}`);
309
+ logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${maxAttempts}): ${formatError(err)}`);
292
310
  }
293
- if (attempt < this.opts.maxAttempts) {
311
+ if (attempt < maxAttempts && Date.now() < deadlineAt) {
294
312
  await this.sleepBackoff(attempt);
295
313
  }
296
314
  }
297
- logger.error(TAG, `GET ${url} failed after ${this.opts.maxAttempts} attempts. userId=${userId}.`);
315
+ logger.error(TAG, `GET ${url} failed after ${maxAttempts} attempt(s)/deadline. userId=${userId}.`);
298
316
  return null;
299
317
  }
300
318
  async sleepBackoff(attempt) {
@@ -14,6 +14,16 @@ export interface ApprovalLifecycleDeps {
14
14
  hasWiring: () => boolean;
15
15
  /** Build a listener bound to THIS adapter. Called only when starting. */
16
16
  buildListener: (adapter: IExchangeAdapter) => StartableListener;
17
+ /** Called when a RUNNING listener was torn down and no replacement started —
18
+ * i.e. the approval path was deliberately disabled (mode flipped away from
19
+ * per_trade, or the live adapter went away). Any proposal still pending is
20
+ * now un-fireable but still approvable on the dashboard, so the caller
21
+ * cancels them (design doc §10 row 12).
22
+ *
23
+ * Deliberately NOT called from stop() — a shutdown drain is a restart, not a
24
+ * disable, and cancelling the operator's live proposals on every plugin
25
+ * restart would be both wrong and obnoxious. */
26
+ onApprovalPathDisabled?: () => void | Promise<void>;
17
27
  }
18
28
  export declare class ApprovalListenerLifecycle {
19
29
  private readonly deps;
@@ -52,14 +52,28 @@ export class ApprovalListenerLifecycle {
52
52
  return this.chain;
53
53
  }
54
54
  async apply(adapter) {
55
+ // A listener was running before this swap? Then if we end up NOT starting a
56
+ // replacement, whatever is still pending has been orphaned.
57
+ const hadListener = this.listener !== undefined;
55
58
  // Always tear down the previous listener first — it is bound to the OLD
56
59
  // adapter and must never fire through it again.
57
60
  await this.stopCurrent();
61
+ const orphaned = async (why) => {
62
+ if (!hadListener || !this.deps.onApprovalPathDisabled)
63
+ return;
64
+ logger.info(TAG, `approval path disabled (${why}) — cancelling any pending proposals`);
65
+ try {
66
+ await this.deps.onApprovalPathDisabled();
67
+ }
68
+ catch (err) {
69
+ logger.warn(TAG, `pending-proposal cancel failed: ${formatError(err)}`);
70
+ }
71
+ };
58
72
  if (!adapter.isLive)
59
- return;
73
+ return orphaned('adapter is no longer live');
60
74
  const mode = this.deps.resolveApprovalMode();
61
75
  if (mode !== 'per_trade')
62
- return;
76
+ return orphaned(`approval.mode=${mode}`);
63
77
  if (!this.deps.hasWiring()) {
64
78
  logger.warn(TAG, "approval.mode='per_trade' but ingest credentials/proposal manager missing — listener NOT started; approvals will not fire");
65
79
  return;
@@ -66,9 +66,18 @@ export class IntelMicrostructureAssembler {
66
66
  return cached.block;
67
67
  // Map canonical "BTC/USDT" → intel "BTCUSDT".
68
68
  const intelSymbol = canonical.replace('/', '');
69
+ // cacheTtlMs mirrors the agent-facing tools (get_resting_liquidity /
70
+ // get_liquidation_pulse) so the assembler and the agent's own calls share
71
+ // one round-trip per heartbeat instead of duplicate-fetching.
69
72
  const [restingResult, pulseResult] = await Promise.allSettled([
70
- fetchIntelApi(`/api/resting-liquidity/${enc(intelSymbol)}`, this.intelDeps),
71
- fetchIntelApi(`/api/liquidation-pulse?symbol=${enc(intelSymbol)}&window_seconds=60`, this.intelDeps),
73
+ fetchIntelApi(`/api/resting-liquidity/${enc(intelSymbol)}`, this.intelDeps, {
74
+ cacheTtlMs: 15_000,
75
+ timeoutMs: 10_000,
76
+ }),
77
+ fetchIntelApi(`/api/liquidation-pulse?symbol=${enc(intelSymbol)}&window_seconds=60`, this.intelDeps, {
78
+ cacheTtlMs: 10_000,
79
+ timeoutMs: 10_000,
80
+ }),
72
81
  ]);
73
82
  const block = { symbol: canonical, updatedAt: ts };
74
83
  let anySignal = false;