@reefclaw/openclaw-plugin 0.1.13 → 0.1.15

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 (71) hide show
  1. package/bridge/bridge.d.ts +20 -5
  2. package/bridge/bridge.js +29 -14
  3. package/bridge/config.js +6 -0
  4. package/bridge/gateway/gateway-config.d.ts +16 -5
  5. package/bridge/gateway/gateway-config.js +68 -12
  6. package/bridge/gateway/gateway-ws-client.d.ts +4 -1
  7. package/bridge/gateway/gateway-ws-client.js +41 -11
  8. package/bridge/gateway/poller.js +18 -8
  9. package/bridge/providers/emergency-commands.d.ts +9 -1
  10. package/bridge/providers/emergency-commands.js +38 -1
  11. package/bridge/providers/gateway.d.ts +51 -1
  12. package/bridge/providers/gateway.js +209 -22
  13. package/bridge/providers/onboarding-commands.d.ts +11 -0
  14. package/bridge/providers/onboarding-commands.js +5 -5
  15. package/bridge/providers/risk-calculator.d.ts +61 -2
  16. package/bridge/providers/risk-calculator.js +92 -20
  17. package/bridge/utils/skill-signing.js +8 -3
  18. package/ccxt/binance-public.d.ts +17 -5
  19. package/ccxt/binance-public.js +31 -3
  20. package/config/operator-provenance.d.ts +6 -0
  21. package/config/operator-provenance.js +50 -0
  22. package/config/plugin-config-io.d.ts +15 -1
  23. package/config/plugin-config-io.js +29 -0
  24. package/exchange-adapter.d.ts +13 -0
  25. package/index.js +230 -176
  26. package/ingest/event-loop-monitor.d.ts +22 -0
  27. package/ingest/event-loop-monitor.js +190 -0
  28. package/ingest/position-auto-capture.d.ts +5 -0
  29. package/ingest/position-auto-capture.js +14 -5
  30. package/ingest/readiness-reporter.d.ts +26 -6
  31. package/ingest/readiness-reporter.js +137 -9
  32. package/ingest/skill-version-reader.d.ts +16 -0
  33. package/ingest/skill-version-reader.js +64 -0
  34. package/live/approval-lifecycle.d.ts +30 -0
  35. package/live/approval-lifecycle.js +80 -0
  36. package/live/bracket-types.d.ts +9 -0
  37. package/live/live-adapter.d.ts +0 -1
  38. package/live/user-data-stream.js +10 -2
  39. package/onboarding/runtime.d.ts +34 -1
  40. package/onboarding/runtime.js +56 -5
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +6 -5
  43. package/risk/pre-trade-check.js +18 -5
  44. package/simulator/exchange-simulator.d.ts +45 -2
  45. package/simulator/exchange-simulator.js +96 -4
  46. package/simulator/types.d.ts +17 -0
  47. package/skills/reefclaw/SKILL.md +6 -11
  48. package/strategy/condition-registry.js +9 -2
  49. package/strategy/evaluator.d.ts +5 -0
  50. package/tools/attach-brackets.js +50 -1
  51. package/tools/cancel-all-orders.js +9 -1
  52. package/tools/create-order.js +18 -1
  53. package/tools/get-bracket-config.d.ts +21 -2
  54. package/tools/get-bracket-config.js +18 -2
  55. package/tools/set-trading-mode.js +6 -3
  56. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
  57. package/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
  58. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  59. package/venues/hyperliquid/hl-brackets.js +45 -13
  60. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  61. package/venues/hyperliquid/hl-fill-ingest.js +88 -0
  62. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  63. package/venues/hyperliquid/hl-live-adapter.js +116 -7
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
  68. package/venues/registry.js +8 -7
  69. package/wave9/paper-admission-guard.d.ts +12 -1
  70. package/wave9/paper-admission-guard.js +12 -1
  71. package/scripts/assemble.mjs +0 -130
package/index.js CHANGED
@@ -29,6 +29,7 @@ import { loadUserDataStreamMode, loadUserDataStreamTunables, loadUserDataStreamD
29
29
  import { TradeStoreClient } from './ingest/trade-store-client.js';
30
30
  import { ProposalManager } from './live/proposal-manager.js';
31
31
  import { ProposalDecisionListener } from './live/proposal-decision-listener.js';
32
+ import { ApprovalListenerLifecycle } from './live/approval-lifecycle.js';
32
33
  import { PositionDecisionsClient } from './ingest/position-decisions-client.js';
33
34
  import { PositionStateStore } from './live/position-state-store.js';
34
35
  import { PendingEntryStore } from './ingest/pending-entry-metadata.js';
@@ -48,7 +49,8 @@ import { queryReviewOutcomesTool } from './tools/query-review-outcomes.js';
48
49
  import { loadPositionReviewMode } from './config/position-review-config.js';
49
50
  import { installSignalHandlers } from './lifecycle/install-signal-handlers.js';
50
51
  import { SerialTradingOperationLock } from './lifecycle/trading-operation-lock.js';
51
- import { readPluginConfig, readOpenClawConnection, loadStopWatcherIntervalMs } from './config/plugin-config-io.js';
52
+ import { readPluginConfig, readOpenClawConnection, loadMicroLiveConfig, loadStopWatcherIntervalMs } from './config/plugin-config-io.js';
53
+ import { verifyOperatorProvenance } from './config/operator-provenance.js';
52
54
  import { startConnectorSupervisor, hasBundledBridge } from './connector-supervisor.js';
53
55
  import { ToolGate } from './config/tool-gate.js';
54
56
  import { gateStore } from './config/gate-store.js';
@@ -795,6 +797,7 @@ const TOOL_PARAMS = {
795
797
  set_exchange_credentials: {
796
798
  type: 'object',
797
799
  properties: {
800
+ operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
798
801
  apiKey: { type: 'string', description: 'Binance API key' },
799
802
  secret: { type: 'string', description: 'Binance API secret' },
800
803
  testnet: { type: 'boolean', description: 'Use Binance testnet (default: false)' },
@@ -804,6 +807,7 @@ const TOOL_PARAMS = {
804
807
  set_trading_mode: {
805
808
  type: 'object',
806
809
  properties: {
810
+ operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
807
811
  mode: { type: 'string', enum: ['PAPER', 'MICRO_LIVE', 'LIVE'], description: 'Target trading mode' },
808
812
  acknowledged: { type: 'boolean', description: 'Required when moving to LIVE — explicit operator acknowledgment' },
809
813
  },
@@ -812,6 +816,7 @@ const TOOL_PARAMS = {
812
816
  test_exchange_credentials: {
813
817
  type: 'object',
814
818
  properties: {
819
+ operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
815
820
  apiKey: { type: 'string', description: 'Binance API key to verify (not persisted)' },
816
821
  secret: { type: 'string', description: 'Binance API secret to verify (not persisted)' },
817
822
  testnet: { type: 'boolean', description: 'Test against Binance testnet' },
@@ -821,6 +826,7 @@ const TOOL_PARAMS = {
821
826
  clear_exchange_credentials: {
822
827
  type: 'object',
823
828
  properties: {
829
+ operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
824
830
  confirm: { type: 'boolean', description: 'Must be true — safety gate against accidental probe wipes' },
825
831
  },
826
832
  required: ['confirm'],
@@ -832,6 +838,7 @@ const TOOL_PARAMS = {
832
838
  set_bracket_requirement: {
833
839
  type: 'object',
834
840
  properties: {
841
+ operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
835
842
  flag: { type: 'string', enum: ['requireStopLoss', 'requireTakeProfit'], description: 'Which requirement to flip' },
836
843
  value: { type: 'boolean', description: 'New value. False disables the requirement and returns a warning banner copy.' },
837
844
  },
@@ -1171,23 +1178,123 @@ const paperTradingPlugin = {
1171
1178
  if (positionDecisionsClient) {
1172
1179
  logger.info(TAG, `Journal close-on-reduce-only-fill ${closeOnReduceOnlyFill ? 'ENABLED' : 'disabled'}`);
1173
1180
  }
1174
- // ---- Create exchange adapter based on trading mode ----
1175
- let adapter;
1176
- // Hoisted so the SIGTERM handler (declared after this block) can drain it
1177
- // on shutdown. Only populated in live modes when dbWrite=on + env present.
1181
+ // Reconciler-driven close-bypass cleanup, installed on EVERY live adapter —
1182
+ // the boot one below AND every reconnect-built one (via
1183
+ // runtime.onAdapterCreated; audit 2026-07-26 F9 previously boot-only, so
1184
+ // a dashboard mode/credential reconnect silently lost the journal cleanup).
1185
+ // The reconciler emits 'drift_detected' on every poll; we filter for
1186
+ // 'closed' drifts (position observed flat on exchange while local thinks
1187
+ // it's open). Fires for bracket SL/TP fills, opposite-side market orders,
1188
+ // and external closes — all paths that bypass close_position() and would
1189
+ // otherwise leave webapp DB + state-store stale.
1190
+ // See plugin/src/ingest/reconciler-cleanup.ts.
1191
+ const installDriftCleanup = (a) => {
1192
+ a.on('drift_detected', (result) => {
1193
+ for (const drift of result.drifts) {
1194
+ if (drift.type !== 'closed')
1195
+ continue;
1196
+ void onReconcilerObservedClose({
1197
+ decisionsClient: positionDecisionsClient,
1198
+ stateStore: positionStateStore,
1199
+ userId: positionDecisionsUserId,
1200
+ lastPriceFn: (sym) => a.getLastPrice(sym),
1201
+ }, {
1202
+ symbol: drift.symbol,
1203
+ lastContracts: drift.localContracts,
1204
+ observedAtMs: new Date(result.timestamp).getTime(),
1205
+ }).catch((err) => {
1206
+ logger.error(TAG, `reconciler cleanup failed for ${drift.symbol}: ${formatError(err)}`);
1207
+ });
1208
+ }
1209
+ });
1210
+ };
1211
+ // ---- Ingest + approval wiring (audit 2026-07-26 F10: BOOT-MODE-INDEPENDENT) ----
1212
+ // Built whenever credentials allow — NOT only on live boots. Every real
1213
+ // trader onboards in PAPER and goes live later from the dashboard; wiring
1214
+ // that only a live BOOT could construct silently never existed on their
1215
+ // box (no audit-trail ingest, no approval listener), and "restart the
1216
+ // container" is not an instruction we can hand a trader. These clients
1217
+ // are passive until a live adapter carries them — F9's runtime wiring
1218
+ // threads them into every adapter build, boot and reconnect alike.
1178
1219
  let tradeIngest;
1179
- // Approval-mode Phase A — set when APPROVAL_SHADOW_MODE=1 + ingest deps present.
1180
- // Read by the create_order tool below to dual-write proposals. See §12 of
1181
- // docs/APPROVAL_MODE_DESIGN.md.
1220
+ {
1221
+ const dbWriteMode = loadUserDataStreamDbWrite();
1222
+ let cfgForIngest;
1223
+ try {
1224
+ cfgForIngest = readPluginConfig();
1225
+ }
1226
+ catch {
1227
+ cfgForIngest = undefined;
1228
+ }
1229
+ const ingestToken = resolveIngestToken(cfgForIngest);
1230
+ const reefclawUserId = resolveReefclawUserId();
1231
+ // The Binance read path only runs when userDataStream.mode != 'off';
1232
+ // the HL user stream ALWAYS runs in live (no mode ladder), so on that
1233
+ // venue dbWrite + credentials alone decide (audit F26).
1234
+ const wsReadPathActive = venue === 'hyperliquid' || loadUserDataStreamMode() !== 'off';
1235
+ if (wsReadPathActive && dbWriteMode === 'on' && ingestToken && reefclawUserId) {
1236
+ const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForIngest);
1237
+ tradeIngest = {
1238
+ client: new TradeStoreClient({ baseUrl: ingestBaseUrl, ingestToken }),
1239
+ userId: reefclawUserId,
1240
+ // Venue-derived FillEvent.exchange — half of the audit-trail
1241
+ // idempotency key; sourced from fillExchangeId, never a literal.
1242
+ // (Reconnect builds re-stamp it from the TARGET venue — F9.)
1243
+ exchange: fillExchangeId(venue),
1244
+ };
1245
+ logger.info(TAG, `User-data stream dbWrite=on — WS audit-trail ingest wired to ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1246
+ }
1247
+ else if (wsReadPathActive && dbWriteMode === 'on') {
1248
+ logger.warn(TAG, `User-data stream dbWrite=on but ingest token (plugin-config connectionToken / WEBAPP_INGEST_TOKEN) or REEFCLAW_USER_ID missing — audit-trail ingest disabled`);
1249
+ }
1250
+ }
1251
+ // Approval mode is resolved PER READ (config + env), not snapshotted at
1252
+ // boot: a config change applies on the next create_order call and the
1253
+ // next adapter swap — no restart (F10; see docs/CLAUDE/approval-mode.md).
1254
+ // per_trade beats shadow when both are set (no point dual-writing shadow
1255
+ // rows while collecting real ones). See docs/APPROVAL_MODE_DESIGN.md §12.
1256
+ const approvalShadowEnabled = (process.env.APPROVAL_SHADOW_MODE ?? '').trim() === '1';
1257
+ const resolveApprovalMode = () => {
1258
+ try {
1259
+ if (readPluginConfig().approval?.mode === 'per_trade')
1260
+ return 'per_trade';
1261
+ }
1262
+ catch { /* config unreadable — env-only fallback below */ }
1263
+ return approvalShadowEnabled ? 'shadow' : 'off';
1264
+ };
1265
+ // Proposal manager + listener credentials: built whenever ingest
1266
+ // credentials exist (passive HTTP client), so a later approval.mode or
1267
+ // trading-mode change finds them ready instead of needing a restart.
1182
1268
  let proposalManagerCtx;
1183
1269
  let proposalListenerCredentials;
1184
- // Approval mode read at init from env + plugin-config.json; passed to the
1185
- // create_order tool so it picks the right branch (off / shadow / per_trade).
1186
- let approvalModeForTool = 'off';
1187
- // Approval-mode Phase B — listener that fires operator-approved proposals.
1188
- // Started below only when config.approval.mode === 'per_trade' AND we have
1189
- // ingest credentials. See docs/APPROVAL_MODE_DESIGN.md §2 + §7.3.
1190
- let proposalDecisionListener;
1270
+ {
1271
+ let cfgForUrl;
1272
+ try {
1273
+ cfgForUrl = readPluginConfig();
1274
+ }
1275
+ catch {
1276
+ cfgForUrl = undefined;
1277
+ }
1278
+ const ingestToken = resolveIngestToken(cfgForUrl);
1279
+ const reefclawUserId = resolveReefclawUserId();
1280
+ const bootApprovalMode = resolveApprovalMode();
1281
+ if (ingestToken && reefclawUserId) {
1282
+ const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForUrl);
1283
+ proposalManagerCtx = {
1284
+ manager: new ProposalManager({ baseUrl: ingestBaseUrl, ingestToken }),
1285
+ userId: reefclawUserId,
1286
+ };
1287
+ proposalListenerCredentials = { ingestToken, userId: reefclawUserId };
1288
+ if (bootApprovalMode !== 'off') {
1289
+ logger.info(TAG, `Approval wiring active (mode=${bootApprovalMode}) → ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1290
+ }
1291
+ }
1292
+ else if (bootApprovalMode !== 'off') {
1293
+ logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing — proposal path disabled (orders fire directly)`);
1294
+ }
1295
+ }
1296
+ // ---- Create exchange adapter based on trading mode ----
1297
+ let adapter;
1191
1298
  if (tradingMode === 'MICRO_LIVE' || tradingMode === 'LIVE') {
1192
1299
  // Per-VENUE credential presence (rehearsal find #4): this safety check
1193
1300
  // predated the venue seam and tested only the Binance shape, so a
@@ -1203,21 +1310,9 @@ const paperTradingPlugin = {
1203
1310
  tradingMode = 'PAPER';
1204
1311
  }
1205
1312
  else {
1206
- // Read micro-live config
1207
- let microLiveConfig;
1208
- try {
1209
- const rcConfigPath = join(homedir(), '.reefclaw', 'plugin-config.json');
1210
- if (existsSync(rcConfigPath)) {
1211
- const rcConfig = JSON.parse(readFileSync(rcConfigPath, 'utf-8'));
1212
- if (rcConfig?.microLive) {
1213
- microLiveConfig = {
1214
- sizeCapPercent: rcConfig.microLive.sizeCapPercent,
1215
- maxPositionUSDT: rcConfig.microLive.maxPositionUSDT,
1216
- };
1217
- }
1218
- }
1219
- }
1220
- catch { /* already read above, best-effort */ }
1313
+ // Micro-live cap — same loader every runtime reconnect uses
1314
+ // (buildAdapter), so boot and reconnect can never disagree (F8).
1315
+ const microLiveConfig = loadMicroLiveConfig();
1221
1316
  // Bracket-orders feature flag read from plugin-config at construction time.
1222
1317
  // Default 'off' keeps legacy stop-watcher behaviour while the feature rolls out.
1223
1318
  const bracketMode = loadBracketMode();
@@ -1232,95 +1327,8 @@ const paperTradingPlugin = {
1232
1327
  if (userDataStreamMode !== 'off') {
1233
1328
  logger.info(TAG, `User-data stream enabled in mode=${userDataStreamMode}`);
1234
1329
  }
1235
- // TRADE_AUDIT_TRAIL_PLAN Phase 1 opt-in WS audit-trail ingest.
1236
- // Activates only when ALL of:
1237
- // - userDataStream.mode != 'off' (read path running)
1238
- // - userDataStream.dbWrite = 'on' (operator opted in)
1239
- // - ingest token resolvable (plugin-config connectionToken,
1240
- // else legacy WEBAPP_INGEST_TOKEN env)
1241
- // - REEFCLAW_USER_ID env set (trader identity)
1242
- // Any missing field → no ingest wired (silent dead-code default).
1243
- const dbWriteMode = loadUserDataStreamDbWrite();
1244
- let cfgForIngest;
1245
- try {
1246
- cfgForIngest = readPluginConfig();
1247
- }
1248
- catch {
1249
- cfgForIngest = undefined;
1250
- }
1251
- const ingestToken = resolveIngestToken(cfgForIngest);
1252
- const reefclawUserId = resolveReefclawUserId();
1253
- if (userDataStreamMode !== 'off' &&
1254
- dbWriteMode === 'on' &&
1255
- ingestToken &&
1256
- reefclawUserId) {
1257
- const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForIngest);
1258
- tradeIngest = {
1259
- client: new TradeStoreClient({ baseUrl: ingestBaseUrl, ingestToken }),
1260
- userId: reefclawUserId,
1261
- // Venue-derived FillEvent.exchange — 'binance_futures' for the
1262
- // binance venue, i.e. byte-identical to the pre-venue literal
1263
- // WsIngest defaulted to. Half of the (exchange, exchange_trade_id)
1264
- // audit-trail idempotency key; never a fresh string literal.
1265
- exchange: fillExchangeId(venue),
1266
- };
1267
- logger.info(TAG, `User-data stream dbWrite=on — WS audit-trail ingest wired to ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1268
- }
1269
- else if (userDataStreamMode !== 'off' && dbWriteMode === 'on') {
1270
- logger.warn(TAG, `User-data stream dbWrite=on but ingest token (plugin-config connectionToken / WEBAPP_INGEST_TOKEN) or REEFCLAW_USER_ID missing — audit-trail ingest disabled`);
1271
- }
1272
- // ---- Approval-mode wiring ----
1273
- // Two switches gate proposal-related behaviour:
1274
- //
1275
- // 1. config approval.mode='per_trade' → THE feature. create_order
1276
- // returns pending_approval (does NOT fire). ProposalDecisionListener
1277
- // polls /pending-decisions and fires when the operator approves.
1278
- //
1279
- // 2. env APPROVAL_SHADOW_MODE=1 → Phase A telemetry only.
1280
- // create_order dual-writes a shadow proposal row AND fires the
1281
- // real order. Used to collect proposal-shape distribution before
1282
- // flipping per_trade. Retired once per_trade has soaked.
1283
- //
1284
- // If both are set, per_trade wins — no point dual-writing shadow rows
1285
- // when we're collecting real ones. The ProposalManager itself is the
1286
- // same instance either way; `origin` on each insert distinguishes them.
1287
- //
1288
- // See docs/APPROVAL_MODE_DESIGN.md §12.
1289
- const approvalShadowEnabled = (process.env.APPROVAL_SHADOW_MODE ?? '').trim() === '1';
1290
- try {
1291
- const cfg = readPluginConfig();
1292
- if (cfg.approval?.mode === 'per_trade') {
1293
- approvalModeForTool = 'per_trade';
1294
- }
1295
- else if (approvalShadowEnabled) {
1296
- approvalModeForTool = 'shadow';
1297
- }
1298
- }
1299
- catch {
1300
- // Config read failed; fall back to env-only.
1301
- if (approvalShadowEnabled)
1302
- approvalModeForTool = 'shadow';
1303
- }
1304
- if (approvalModeForTool !== 'off' && ingestToken && reefclawUserId) {
1305
- let cfgForUrl;
1306
- try {
1307
- cfgForUrl = readPluginConfig();
1308
- }
1309
- catch {
1310
- cfgForUrl = undefined;
1311
- }
1312
- const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForUrl);
1313
- proposalManagerCtx = {
1314
- manager: new ProposalManager({ baseUrl: ingestBaseUrl, ingestToken }),
1315
- userId: reefclawUserId,
1316
- };
1317
- proposalListenerCredentials = { ingestToken, userId: reefclawUserId };
1318
- logger.info(TAG, `Approval wiring active (mode=${approvalModeForTool}) → ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1319
- }
1320
- else if (approvalModeForTool !== 'off') {
1321
- logger.warn(TAG, `approval mode=${approvalModeForTool} but WEBAPP_INGEST_TOKEN or REEFCLAW_USER_ID env missing — proposal path disabled`);
1322
- approvalModeForTool = 'off';
1323
- }
1330
+ // (Ingest + approval wiring is hoisted above the mode branch — F10:
1331
+ // it must exist on PAPER boots too, so a runtime live-flip finds it.)
1324
1332
  // Venue-dispatched construction (multi-venue Phase 0). For 'binance'
1325
1333
  // this is a pure pass-through to `new LiveAdapter(...)` — identical
1326
1334
  // args, identical behavior; the unsupported-venue arm is unreachable
@@ -1334,6 +1342,10 @@ const paperTradingPlugin = {
1334
1342
  credentials: hlCredentials,
1335
1343
  mode: tradingMode,
1336
1344
  marketSlippagePct: readPluginConfig().hl?.marketSlippagePct,
1345
+ microLive: microLiveConfig,
1346
+ // F26: same wiring object as the Binance arm — the SIGTERM
1347
+ // drain covers both venues because it drains this client.
1348
+ tradeIngest,
1337
1349
  },
1338
1350
  }
1339
1351
  : {
@@ -1350,31 +1362,9 @@ const paperTradingPlugin = {
1350
1362
  ],
1351
1363
  });
1352
1364
  adapter = liveAdapter;
1353
- // Reconciler-driven close-bypass cleanup. The reconciler emits
1354
- // 'drift_detected' on every poll; we filter for 'closed' drifts
1355
- // (position observed flat on exchange while local thinks it's open).
1356
- // Fires for bracket SL/TP fills, opposite-side market orders, and
1357
- // external closes — all paths that bypass close_position() and
1358
- // would otherwise leave webapp DB + state-store stale.
1359
- // See plugin/src/ingest/reconciler-cleanup.ts.
1360
- liveAdapter.on('drift_detected', (result) => {
1361
- for (const drift of result.drifts) {
1362
- if (drift.type !== 'closed')
1363
- continue;
1364
- void onReconcilerObservedClose({
1365
- decisionsClient: positionDecisionsClient,
1366
- stateStore: positionStateStore,
1367
- userId: positionDecisionsUserId,
1368
- lastPriceFn: (sym) => liveAdapter.getLastPrice(sym),
1369
- }, {
1370
- symbol: drift.symbol,
1371
- lastContracts: drift.localContracts,
1372
- observedAtMs: new Date(result.timestamp).getTime(),
1373
- }).catch((err) => {
1374
- logger.error(TAG, `reconciler cleanup failed for ${drift.symbol}: ${formatError(err)}`);
1375
- });
1376
- }
1377
- });
1365
+ // Journal close-bypass cleanup shared installer (see its definition
1366
+ // above; the runtime re-installs it on every reconnect-built adapter).
1367
+ installDriftCleanup(liveAdapter);
1378
1368
  // Post-registration async init (fire-and-forget — register must be sync)
1379
1369
  liveAdapter.initialize().then(async () => {
1380
1370
  // After init, run a one-shot startup reconciliation between
@@ -1556,6 +1546,24 @@ const paperTradingPlugin = {
1556
1546
  });
1557
1547
  };
1558
1548
  wireWatcherJournalCapture(bootstrapWatcher);
1549
+ // Journal capture for the paper take-profit leg. Same contract as the
1550
+ // watcher wiring above: a target close bypasses close_position, so without
1551
+ // this it would leave a phantom-open journal row (the issue #199 class).
1552
+ // Wired once — the simulator instance outlives adapter swaps (every
1553
+ // PaperAdapter is constructed around this same object), unlike watchers.
1554
+ simulator.on('target_closed', (ev) => {
1555
+ onStopWatcherClose(autoCapture, {
1556
+ symbol: ev.symbol,
1557
+ leg: 'exchange_target',
1558
+ // The breached level and the price that breached it — the assessment
1559
+ // records both, so a gap past the target stays visible in the journal.
1560
+ stopPrice: ev.targetPrice,
1561
+ markPrice: ev.markPrice,
1562
+ order: ev.order,
1563
+ }).catch((err) => {
1564
+ logger.warn(TAG, `take-profit close capture failed: ${formatError(err)}`);
1565
+ });
1566
+ });
1559
1567
  const runtime = new PluginRuntime({
1560
1568
  adapter,
1561
1569
  mode: tradingMode,
@@ -1564,6 +1572,16 @@ const paperTradingPlugin = {
1564
1572
  marketFeed,
1565
1573
  operationLock: wave9OperationLock,
1566
1574
  onWatcherCreated: wireWatcherJournalCapture,
1575
+ // F9: reconnect-built adapters get the SAME boot wiring — drift-cleanup
1576
+ // listener + audit-trail ingest client + journal auto-capture — instead
1577
+ // of silently reverting to bare adapters until the next restart.
1578
+ onAdapterCreated: installDriftCleanup,
1579
+ liveWiring: {
1580
+ tradeIngestBase: tradeIngest
1581
+ ? { client: tradeIngest.client, userId: tradeIngest.userId }
1582
+ : undefined,
1583
+ autoCapture,
1584
+ },
1567
1585
  });
1568
1586
  // Periodic DB-vs-exchange sweep (issues #199/#203): the boot-only reconcile
1569
1587
  // left phantom-open journal rows alive for days between restarts. Runs for
@@ -1798,40 +1816,43 @@ const paperTradingPlugin = {
1798
1816
  // If the ledger failed to load, Wave 9 admission is disabled and generic
1799
1817
  // watcher behavior still starts normally here.
1800
1818
  bootstrapWatcher.start();
1801
- // Start the approved-order listener only after runtime, the shared
1802
- // mutation lock, and durable Wave 9 ownership state are fully resolved.
1803
- if (runtime.adapter.isLive
1804
- && approvalModeForTool === 'per_trade'
1805
- && proposalManagerCtx
1806
- && proposalListenerCredentials) {
1807
- try {
1808
- const approvalCfg = readPluginConfig().approval;
1819
+ // Approved-order listener lifecycle (F10): follows ADAPTER SWAPS, not boot
1820
+ // state. A PAPER-boot box that goes live from the dashboard starts the
1821
+ // listener at the flip; a live→PAPER flip stops it (previously it stayed
1822
+ // wired to the ORPHANED live adapter — an operator approval would have
1823
+ // fired a real exchange order while the dashboard said PAPER); a
1824
+ // credential swap rebinds it to the fresh adapter. Started only after
1825
+ // runtime, the shared mutation lock, and durable Wave 9 ownership state
1826
+ // are fully resolved — same ordering the boot-only version had.
1827
+ const approvalLifecycle = new ApprovalListenerLifecycle({
1828
+ resolveApprovalMode,
1829
+ hasWiring: () => proposalManagerCtx !== undefined && proposalListenerCredentials !== undefined,
1830
+ buildListener: (boundAdapter) => {
1831
+ let approvalCfg;
1809
1832
  let cfgForUrl;
1810
1833
  try {
1811
1834
  cfgForUrl = readPluginConfig();
1835
+ approvalCfg = cfgForUrl.approval;
1812
1836
  }
1813
1837
  catch {
1814
1838
  cfgForUrl = undefined;
1815
1839
  }
1816
- const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForUrl);
1817
- proposalDecisionListener = new ProposalDecisionListener({
1818
- baseUrl: ingestBaseUrl,
1840
+ return new ProposalDecisionListener({
1841
+ baseUrl: getUserDataStreamIngestBaseUrl(cfgForUrl),
1819
1842
  ingestToken: proposalListenerCredentials.ingestToken,
1820
1843
  userId: proposalListenerCredentials.userId,
1821
- adapter: runtime.adapter,
1844
+ adapter: boundAdapter,
1822
1845
  binanceApi,
1823
1846
  autoCapture,
1824
1847
  operationLock: wave9OperationLock,
1825
1848
  checkWave9LiveSymbolOwnership,
1826
1849
  pollIntervalMs: approvalCfg?.pollIntervalMs ?? 3_000,
1827
1850
  });
1828
- proposalDecisionListener.start();
1829
- logger.info(TAG, `ProposalDecisionListener started (poll ${approvalCfg?.pollIntervalMs ?? 3_000} ms)`);
1830
- }
1831
- catch (err) {
1832
- logger.warn(TAG, `Failed to start ProposalDecisionListener: ${String(err)}`);
1833
- }
1834
- }
1851
+ },
1852
+ });
1853
+ runtime.setOnAdapterSwapped((a) => { void approvalLifecycle.onAdapterSwapped(a); });
1854
+ // Boot application — the same path every later swap takes.
1855
+ void approvalLifecycle.onAdapterSwapped(runtime.adapter);
1835
1856
  const wave9ActivationClient = new IntelWave9ActivationClient(intelApiDeps);
1836
1857
  const getWave9AccountSnapshot = async () => {
1837
1858
  if (!runtime.adapter.isLive) {
@@ -2040,7 +2061,7 @@ const paperTradingPlugin = {
2040
2061
  installSignalHandlers({
2041
2062
  runtime,
2042
2063
  tradeStore: tradeIngest?.client,
2043
- proposalListener: proposalDecisionListener,
2064
+ proposalListener: approvalLifecycle,
2044
2065
  stateManager,
2045
2066
  });
2046
2067
  // Warm the trading params cache (fire-and-forget — register must be sync)
@@ -2137,7 +2158,10 @@ const paperTradingPlugin = {
2137
2158
  autoCapture,
2138
2159
  proposalManager: proposalManagerCtx?.manager,
2139
2160
  userId: proposalManagerCtx?.userId,
2140
- approvalMode: approvalModeForTool,
2161
+ // Per-call read (F10): a config approval.mode change applies on
2162
+ // the NEXT create_order, not the next restart. The deps literal
2163
+ // is rebuilt per execute, so this re-resolves every call.
2164
+ approvalMode: resolveApprovalMode(),
2141
2165
  wave9AdmissionGuard,
2142
2166
  wave9ActivationCheck: () => wave9ActivationClient.getActivation(),
2143
2167
  refreshPaperState: reloadState,
@@ -2615,44 +2639,74 @@ const paperTradingPlugin = {
2615
2639
  {
2616
2640
  name: 'set_exchange_credentials',
2617
2641
  label: 'Set Exchange Credentials',
2618
- description: 'Operator-only. Store Binance API key + secret in ~/.reefclaw/plugin-config.json. If current mode is live, reconnects the adapter. Never called by the agent.',
2642
+ description: 'Operator-only. Store Binance API key + secret in ~/.reefclaw/plugin-config.json. If current mode is live, reconnects the adapter. Refused without dashboard operator provenance.',
2619
2643
  parameters: TOOL_PARAMS.set_exchange_credentials,
2620
- execute: async (_id, params) => jsonResult(await setExchangeCredentialsTool(params, { runtime, adapterDeps })),
2644
+ execute: async (_id, params) => {
2645
+ const prov = verifyOperatorProvenance(params.operator_token);
2646
+ if (!prov.ok)
2647
+ return jsonResult({ error: prov.error });
2648
+ return jsonResult(await setExchangeCredentialsTool(params, { runtime, adapterDeps }));
2649
+ },
2621
2650
  },
2622
2651
  {
2623
2652
  name: 'set_trading_mode',
2624
2653
  label: 'Set Trading Mode',
2625
- description: 'Operator-only. Move the plugin between PAPER / MICRO_LIVE / LIVE. Enforces the one-rung-at-a-time ladder. Never called by the agent.',
2654
+ description: 'Operator-only. Move the plugin between PAPER / MICRO_LIVE / LIVE. Enforces the one-rung-at-a-time ladder. Briefly reconnects the trading adapter (open positions keep their exchange-side protective brackets throughout). Refused without dashboard operator provenance.',
2626
2655
  parameters: TOOL_PARAMS.set_trading_mode,
2627
- execute: async (_id, params) => jsonResult(await setTradingModeTool(params, { runtime, adapterDeps })),
2656
+ execute: async (_id, params) => {
2657
+ const prov = verifyOperatorProvenance(params.operator_token);
2658
+ if (!prov.ok)
2659
+ return jsonResult({ error: prov.error });
2660
+ return jsonResult(await setTradingModeTool(params, { runtime, adapterDeps }));
2661
+ },
2628
2662
  },
2629
2663
  {
2630
2664
  name: 'test_exchange_credentials',
2631
2665
  label: 'Test Exchange Credentials',
2632
- description: 'Operator-only. Verify a Binance API key + secret with a read-only call (fetchBalance) without persisting anything. Used by the dashboard pre-flight check before set_exchange_credentials. Never called by the agent.',
2666
+ description: 'Operator-only. Verify a Binance API key + secret with a read-only call (fetchBalance) without persisting anything. Used by the dashboard pre-flight check before set_exchange_credentials. Refused without dashboard operator provenance.',
2633
2667
  parameters: TOOL_PARAMS.test_exchange_credentials,
2634
- execute: async (_id, params) => jsonResult(await testExchangeCredentialsTool(params)),
2668
+ // Read-only, but provenance-gated anyway: it makes a live authenticated
2669
+ // exchange call with caller-supplied keys, which would otherwise hand a
2670
+ // prompt-injected agent a credential-validation oracle.
2671
+ execute: async (_id, params) => {
2672
+ const prov = verifyOperatorProvenance(params.operator_token);
2673
+ if (!prov.ok)
2674
+ return jsonResult({ error: prov.error });
2675
+ return jsonResult(await testExchangeCredentialsTool(params));
2676
+ },
2635
2677
  },
2636
2678
  {
2637
2679
  name: 'clear_exchange_credentials',
2638
2680
  label: 'Clear Exchange Credentials',
2639
- description: 'Operator-only. Remove stored Binance credentials from plugin-config.json and de-escalate to PAPER mode if currently running in MICRO_LIVE/LIVE. Requires {confirm: true}. Never called by the agent.',
2681
+ description: 'Operator-only. Remove stored Binance credentials from plugin-config.json and de-escalate to PAPER mode if currently running in MICRO_LIVE/LIVE. Requires {confirm: true}. Refused without dashboard operator provenance.',
2640
2682
  parameters: TOOL_PARAMS.clear_exchange_credentials,
2641
- execute: async (_id, params) => jsonResult(await clearExchangeCredentialsTool(params, { runtime, adapterDeps })),
2683
+ execute: async (_id, params) => {
2684
+ const prov = verifyOperatorProvenance(params.operator_token);
2685
+ if (!prov.ok)
2686
+ return jsonResult({ error: prov.error });
2687
+ return jsonResult(await clearExchangeCredentialsTool(params, { runtime, adapterDeps }));
2688
+ },
2642
2689
  },
2643
2690
  {
2644
2691
  name: 'get_bracket_config',
2645
2692
  label: 'Get Bracket Config',
2646
2693
  description: 'Operator-only. Returns the current bracket-orders configuration (mode + requireStopLoss + requireTakeProfit). Used by the dashboard Trading Parameters panel.',
2647
2694
  parameters: TOOL_PARAMS.get_bracket_config,
2648
- execute: async () => jsonResult(getBracketConfigTool({})),
2695
+ // Adapter read at execute time (not registration) so a PAPER→HL-live
2696
+ // swap changes the reported protection without a restart.
2697
+ execute: async () => jsonResult(getBracketConfigTool({}, { adapter: runtime.adapter })),
2649
2698
  },
2650
2699
  {
2651
2700
  name: 'set_bracket_requirement',
2652
2701
  label: 'Set Bracket Requirement',
2653
- description: 'Operator-only. Flip requireStopLoss or requireTakeProfit in plugin-config. Returns a warning copy when a flag is disabled so the dashboard can render the confirmation banner. Never called by the agent.',
2702
+ description: 'Operator-only. Flip requireStopLoss or requireTakeProfit in plugin-config. Returns a warning copy when a flag is disabled so the dashboard can render the confirmation banner. Refused without dashboard operator provenance.',
2654
2703
  parameters: TOOL_PARAMS.set_bracket_requirement,
2655
- execute: async (_id, params) => jsonResult(setBracketRequirementTool(params)),
2704
+ execute: async (_id, params) => {
2705
+ const prov = verifyOperatorProvenance(params.operator_token);
2706
+ if (!prov.ok)
2707
+ return jsonResult({ error: prov.error });
2708
+ return jsonResult(setBracketRequirementTool(params));
2709
+ },
2656
2710
  },
2657
2711
  ];
2658
2712
  // ---- Tool gate (central per-user config, slice 1) ----
@@ -0,0 +1,22 @@
1
+ /** Start sampling. Safe to call repeatedly (idempotent). */
2
+ export declare function startEventLoopMonitor(): void;
3
+ /** Worst event-loop delay (ms) observed since the previous call, then reset —
4
+ * so consecutive calls partition the timeline into non-overlapping windows.
5
+ * Returns null when the monitor is unavailable or has no sample yet.
6
+ *
7
+ * The histogram stores nanoseconds; `max` is Infinity-safe but can read as a
8
+ * sentinel before the first sample lands, so anything non-finite → null. */
9
+ export declare function sampleEventLoopDelayMs(): number | null;
10
+ /** Time (ms) this process spent waiting for a CPU since the previous call, then
11
+ * re-baselines — so consecutive calls partition the timeline into the SAME
12
+ * non-overlapping windows as `sampleEventLoopDelayMs`, and the two readings of
13
+ * one cycle describe one window.
14
+ *
15
+ * ★ Call this EVERY cycle, not only when a stall was observed: the counter is
16
+ * cumulative since process start, so a first read taken at the moment of a
17
+ * freeze would report hours of ordinary scheduling as if it were the freeze.
18
+ *
19
+ * Returns null when unavailable or on the first (baseline-establishing) call. */
20
+ export declare function sampleRunqueueWaitMs(): number | null;
21
+ /** Test-only — drop the singleton so a fresh histogram is created. */
22
+ export declare function __resetEventLoopMonitorForTests(): void;