@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.
- package/assets/bridge/bridge.js +72 -5
- package/assets/bridge/connector.d.ts +3 -1
- package/assets/bridge/connector.js +51 -4
- package/assets/bridge/gateway/heartbeat-cron.js +31 -7
- package/assets/bridge/gateway/poller.d.ts +5 -0
- package/assets/bridge/gateway/poller.js +9 -0
- package/assets/bridge/index.js +4 -0
- package/assets/bridge/provider.d.ts +15 -0
- package/assets/bridge/providers/connector-update.d.ts +89 -0
- package/assets/bridge/providers/connector-update.js +212 -0
- package/assets/bridge/providers/emergency-commands.d.ts +36 -0
- package/assets/bridge/providers/emergency-commands.js +91 -0
- package/assets/bridge/providers/gateway.d.ts +26 -1
- package/assets/bridge/providers/gateway.js +159 -8
- package/assets/bridge/providers/mock.js +1 -0
- package/assets/bridge/types.d.ts +5 -1
- package/assets/bridge/types.js +5 -0
- package/assets/bridge/utils/instance-id.d.ts +3 -0
- package/assets/bridge/utils/instance-id.js +48 -0
- package/assets/plugin/ccxt/binance-private.js +2 -1
- package/assets/plugin/ccxt/binance-public.js +6 -1
- package/assets/plugin/config/agent-config-client.d.ts +5 -2
- package/assets/plugin/config/agent-config-client.js +13 -0
- package/assets/plugin/config/agent-config-poller.js +5 -1
- package/assets/plugin/config/gate-store.d.ts +9 -0
- package/assets/plugin/config/gate-store.js +17 -2
- package/assets/plugin/config/plugin-config-io.js +24 -2
- package/assets/plugin/http/keepalive-fetch.d.ts +5 -0
- package/assets/plugin/http/keepalive-fetch.js +50 -0
- package/assets/plugin/index.js +53 -6
- package/assets/plugin/ingest/position-auto-capture.js +14 -2
- package/assets/plugin/ingest/position-decisions-client.d.ts +6 -0
- package/assets/plugin/ingest/position-decisions-client.js +27 -9
- package/assets/plugin/live/approval-lifecycle.d.ts +10 -0
- package/assets/plugin/live/approval-lifecycle.js +16 -2
- package/assets/plugin/live/microstructure-assembler.js +11 -2
- package/assets/plugin/live/proposal-decision-listener.d.ts +21 -0
- package/assets/plugin/live/proposal-decision-listener.js +39 -0
- package/assets/plugin/live/proposal-manager.d.ts +12 -0
- package/assets/plugin/live/proposal-manager.js +47 -0
- package/assets/plugin/live/stop-watcher.d.ts +16 -1
- package/assets/plugin/live/stop-watcher.js +48 -8
- package/assets/plugin/onboarding/runtime.js +4 -0
- package/assets/plugin/openclaw.plugin.json +1 -1
- package/assets/plugin/persistence/state-manager.d.ts +7 -0
- package/assets/plugin/persistence/state-manager.js +28 -1
- package/assets/plugin/simulator/exchange-simulator.d.ts +27 -1
- package/assets/plugin/simulator/exchange-simulator.js +98 -38
- package/assets/plugin/tools/audit-bracket-protection.js +11 -7
- package/assets/plugin/tools/close-position.js +10 -1
- package/assets/plugin/tools/create-order.js +49 -7
- package/assets/plugin/tools/get-funding-context.js +6 -1
- package/assets/plugin/tools/get-liquidation-levels.js +5 -1
- package/assets/plugin/tools/get-liquidation-pulse.js +7 -1
- package/assets/plugin/tools/get-market-intel.js +2 -1
- package/assets/plugin/tools/get-relevant-learnings.js +20 -1
- package/assets/plugin/tools/get-resting-liquidity.js +6 -1
- package/assets/plugin/tools/get-wave9-status.js +17 -0
- package/assets/plugin/tools/intel-api.d.ts +9 -0
- package/assets/plugin/tools/intel-api.js +32 -1
- package/assets/plugin/tools/record-position-reviews.js +2 -2
- package/assets/plugin/tools/scan-pairs.js +20 -11
- package/assets/plugin/types.d.ts +7 -0
- package/assets/plugin/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
- package/assets/plugin/venues/hyperliquid/hl-live-adapter.js +101 -13
- package/assets/shared/readiness.js +5 -1
- package/dist/cli.js +27 -6
- package/dist/openclaw.js +1 -1
- package/dist/validate.js +46 -9
- package/package.json +33 -32
package/assets/bridge/types.d.ts
CHANGED
|
@@ -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;
|
package/assets/bridge/types.js
CHANGED
|
@@ -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,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
|
|
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
|
|
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`)
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/assets/plugin/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
-
|
|
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 —
|
|
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 {
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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}/${
|
|
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}/${
|
|
309
|
+
logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${maxAttempts}): ${formatError(err)}`);
|
|
292
310
|
}
|
|
293
|
-
if (attempt <
|
|
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 ${
|
|
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
|
-
|
|
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;
|