@reefclaw/openclaw-plugin 0.1.12 → 0.1.14

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 (67) hide show
  1. package/bridge/gateway/event-parser.d.ts +19 -0
  2. package/bridge/gateway/event-parser.js +52 -0
  3. package/bridge/gateway/gateway-config.d.ts +16 -5
  4. package/bridge/gateway/gateway-config.js +68 -12
  5. package/bridge/gateway/heartbeat-cron.d.ts +1 -0
  6. package/bridge/gateway/heartbeat-cron.js +22 -1
  7. package/bridge/gateway/poller.js +18 -8
  8. package/bridge/providers/emergency-commands.d.ts +9 -1
  9. package/bridge/providers/emergency-commands.js +38 -1
  10. package/bridge/providers/gateway.d.ts +72 -1
  11. package/bridge/providers/gateway.js +241 -29
  12. package/bridge/providers/onboarding-commands.d.ts +8 -0
  13. package/bridge/providers/onboarding-commands.js +4 -4
  14. package/bridge/types.d.ts +30 -0
  15. package/bridge/utils/identity-name.d.ts +24 -0
  16. package/bridge/utils/identity-name.js +54 -0
  17. package/ccxt/binance-public.d.ts +21 -7
  18. package/ccxt/binance-public.js +70 -6
  19. package/config/operator-provenance.d.ts +6 -0
  20. package/config/operator-provenance.js +50 -0
  21. package/config/plugin-config-io.d.ts +15 -1
  22. package/config/plugin-config-io.js +24 -0
  23. package/index.js +216 -173
  24. package/ingest/event-loop-monitor.d.ts +11 -0
  25. package/ingest/event-loop-monitor.js +113 -0
  26. package/ingest/position-auto-capture.d.ts +5 -0
  27. package/ingest/position-auto-capture.js +14 -5
  28. package/ingest/readiness-reporter.d.ts +17 -6
  29. package/ingest/readiness-reporter.js +88 -9
  30. package/ingest/skill-version-reader.d.ts +16 -0
  31. package/ingest/skill-version-reader.js +64 -0
  32. package/live/approval-lifecycle.d.ts +30 -0
  33. package/live/approval-lifecycle.js +80 -0
  34. package/live/bracket-types.d.ts +9 -0
  35. package/live/live-adapter.d.ts +0 -1
  36. package/live/proposal-decision-listener.d.ts +20 -0
  37. package/live/proposal-decision-listener.js +211 -48
  38. package/onboarding/runtime.d.ts +34 -1
  39. package/onboarding/runtime.js +56 -5
  40. package/openclaw.plugin.json +1 -1
  41. package/package.json +2 -2
  42. package/simulator/exchange-simulator.d.ts +45 -2
  43. package/simulator/exchange-simulator.js +96 -4
  44. package/simulator/types.d.ts +17 -0
  45. package/tools/attach-brackets.js +50 -1
  46. package/tools/create-order.d.ts +11 -0
  47. package/tools/create-order.js +23 -2
  48. package/tools/get-risk-summary.d.ts +4 -0
  49. package/tools/get-risk-summary.js +62 -23
  50. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
  51. package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
  52. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  53. package/venues/hyperliquid/hl-brackets.js +45 -13
  54. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  55. package/venues/hyperliquid/hl-fill-ingest.js +69 -0
  56. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  57. package/venues/hyperliquid/hl-live-adapter.js +155 -12
  58. package/venues/hyperliquid/hl-order.d.ts +35 -0
  59. package/venues/hyperliquid/hl-order.js +123 -0
  60. package/venues/hyperliquid/hl-position.d.ts +36 -0
  61. package/venues/hyperliquid/hl-position.js +127 -0
  62. package/venues/hyperliquid/hl-private.d.ts +20 -3
  63. package/venues/hyperliquid/hl-private.js +37 -6
  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
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
  },
@@ -821,6 +825,7 @@ const TOOL_PARAMS = {
821
825
  clear_exchange_credentials: {
822
826
  type: 'object',
823
827
  properties: {
828
+ operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
824
829
  confirm: { type: 'boolean', description: 'Must be true — safety gate against accidental probe wipes' },
825
830
  },
826
831
  required: ['confirm'],
@@ -832,6 +837,7 @@ const TOOL_PARAMS = {
832
837
  set_bracket_requirement: {
833
838
  type: 'object',
834
839
  properties: {
840
+ operator_token: { type: 'string', description: 'Operator provenance — injected automatically by the ReefClaw dashboard path. Agent-initiated calls are refused without it.' },
835
841
  flag: { type: 'string', enum: ['requireStopLoss', 'requireTakeProfit'], description: 'Which requirement to flip' },
836
842
  value: { type: 'boolean', description: 'New value. False disables the requirement and returns a warning banner copy.' },
837
843
  },
@@ -1171,23 +1177,123 @@ const paperTradingPlugin = {
1171
1177
  if (positionDecisionsClient) {
1172
1178
  logger.info(TAG, `Journal close-on-reduce-only-fill ${closeOnReduceOnlyFill ? 'ENABLED' : 'disabled'}`);
1173
1179
  }
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.
1180
+ // Reconciler-driven close-bypass cleanup, installed on EVERY live adapter —
1181
+ // the boot one below AND every reconnect-built one (via
1182
+ // runtime.onAdapterCreated; audit 2026-07-26 F9 previously boot-only, so
1183
+ // a dashboard mode/credential reconnect silently lost the journal cleanup).
1184
+ // The reconciler emits 'drift_detected' on every poll; we filter for
1185
+ // 'closed' drifts (position observed flat on exchange while local thinks
1186
+ // it's open). Fires for bracket SL/TP fills, opposite-side market orders,
1187
+ // and external closes — all paths that bypass close_position() and would
1188
+ // otherwise leave webapp DB + state-store stale.
1189
+ // See plugin/src/ingest/reconciler-cleanup.ts.
1190
+ const installDriftCleanup = (a) => {
1191
+ a.on('drift_detected', (result) => {
1192
+ for (const drift of result.drifts) {
1193
+ if (drift.type !== 'closed')
1194
+ continue;
1195
+ void onReconcilerObservedClose({
1196
+ decisionsClient: positionDecisionsClient,
1197
+ stateStore: positionStateStore,
1198
+ userId: positionDecisionsUserId,
1199
+ lastPriceFn: (sym) => a.getLastPrice(sym),
1200
+ }, {
1201
+ symbol: drift.symbol,
1202
+ lastContracts: drift.localContracts,
1203
+ observedAtMs: new Date(result.timestamp).getTime(),
1204
+ }).catch((err) => {
1205
+ logger.error(TAG, `reconciler cleanup failed for ${drift.symbol}: ${formatError(err)}`);
1206
+ });
1207
+ }
1208
+ });
1209
+ };
1210
+ // ---- Ingest + approval wiring (audit 2026-07-26 F10: BOOT-MODE-INDEPENDENT) ----
1211
+ // Built whenever credentials allow — NOT only on live boots. Every real
1212
+ // trader onboards in PAPER and goes live later from the dashboard; wiring
1213
+ // that only a live BOOT could construct silently never existed on their
1214
+ // box (no audit-trail ingest, no approval listener), and "restart the
1215
+ // container" is not an instruction we can hand a trader. These clients
1216
+ // are passive until a live adapter carries them — F9's runtime wiring
1217
+ // threads them into every adapter build, boot and reconnect alike.
1178
1218
  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.
1219
+ {
1220
+ const dbWriteMode = loadUserDataStreamDbWrite();
1221
+ let cfgForIngest;
1222
+ try {
1223
+ cfgForIngest = readPluginConfig();
1224
+ }
1225
+ catch {
1226
+ cfgForIngest = undefined;
1227
+ }
1228
+ const ingestToken = resolveIngestToken(cfgForIngest);
1229
+ const reefclawUserId = resolveReefclawUserId();
1230
+ // The Binance read path only runs when userDataStream.mode != 'off';
1231
+ // the HL user stream ALWAYS runs in live (no mode ladder), so on that
1232
+ // venue dbWrite + credentials alone decide (audit F26).
1233
+ const wsReadPathActive = venue === 'hyperliquid' || loadUserDataStreamMode() !== 'off';
1234
+ if (wsReadPathActive && dbWriteMode === 'on' && ingestToken && reefclawUserId) {
1235
+ const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForIngest);
1236
+ tradeIngest = {
1237
+ client: new TradeStoreClient({ baseUrl: ingestBaseUrl, ingestToken }),
1238
+ userId: reefclawUserId,
1239
+ // Venue-derived FillEvent.exchange — half of the audit-trail
1240
+ // idempotency key; sourced from fillExchangeId, never a literal.
1241
+ // (Reconnect builds re-stamp it from the TARGET venue — F9.)
1242
+ exchange: fillExchangeId(venue),
1243
+ };
1244
+ logger.info(TAG, `User-data stream dbWrite=on — WS audit-trail ingest wired to ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1245
+ }
1246
+ else if (wsReadPathActive && dbWriteMode === 'on') {
1247
+ 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`);
1248
+ }
1249
+ }
1250
+ // Approval mode is resolved PER READ (config + env), not snapshotted at
1251
+ // boot: a config change applies on the next create_order call and the
1252
+ // next adapter swap — no restart (F10; see docs/CLAUDE/approval-mode.md).
1253
+ // per_trade beats shadow when both are set (no point dual-writing shadow
1254
+ // rows while collecting real ones). See docs/APPROVAL_MODE_DESIGN.md §12.
1255
+ const approvalShadowEnabled = (process.env.APPROVAL_SHADOW_MODE ?? '').trim() === '1';
1256
+ const resolveApprovalMode = () => {
1257
+ try {
1258
+ if (readPluginConfig().approval?.mode === 'per_trade')
1259
+ return 'per_trade';
1260
+ }
1261
+ catch { /* config unreadable — env-only fallback below */ }
1262
+ return approvalShadowEnabled ? 'shadow' : 'off';
1263
+ };
1264
+ // Proposal manager + listener credentials: built whenever ingest
1265
+ // credentials exist (passive HTTP client), so a later approval.mode or
1266
+ // trading-mode change finds them ready instead of needing a restart.
1182
1267
  let proposalManagerCtx;
1183
1268
  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;
1269
+ {
1270
+ let cfgForUrl;
1271
+ try {
1272
+ cfgForUrl = readPluginConfig();
1273
+ }
1274
+ catch {
1275
+ cfgForUrl = undefined;
1276
+ }
1277
+ const ingestToken = resolveIngestToken(cfgForUrl);
1278
+ const reefclawUserId = resolveReefclawUserId();
1279
+ const bootApprovalMode = resolveApprovalMode();
1280
+ if (ingestToken && reefclawUserId) {
1281
+ const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForUrl);
1282
+ proposalManagerCtx = {
1283
+ manager: new ProposalManager({ baseUrl: ingestBaseUrl, ingestToken }),
1284
+ userId: reefclawUserId,
1285
+ };
1286
+ proposalListenerCredentials = { ingestToken, userId: reefclawUserId };
1287
+ if (bootApprovalMode !== 'off') {
1288
+ logger.info(TAG, `Approval wiring active (mode=${bootApprovalMode}) → ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1289
+ }
1290
+ }
1291
+ else if (bootApprovalMode !== 'off') {
1292
+ logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing — proposal path disabled (orders fire directly)`);
1293
+ }
1294
+ }
1295
+ // ---- Create exchange adapter based on trading mode ----
1296
+ let adapter;
1191
1297
  if (tradingMode === 'MICRO_LIVE' || tradingMode === 'LIVE') {
1192
1298
  // Per-VENUE credential presence (rehearsal find #4): this safety check
1193
1299
  // predated the venue seam and tested only the Binance shape, so a
@@ -1203,21 +1309,9 @@ const paperTradingPlugin = {
1203
1309
  tradingMode = 'PAPER';
1204
1310
  }
1205
1311
  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 */ }
1312
+ // Micro-live cap — same loader every runtime reconnect uses
1313
+ // (buildAdapter), so boot and reconnect can never disagree (F8).
1314
+ const microLiveConfig = loadMicroLiveConfig();
1221
1315
  // Bracket-orders feature flag read from plugin-config at construction time.
1222
1316
  // Default 'off' keeps legacy stop-watcher behaviour while the feature rolls out.
1223
1317
  const bracketMode = loadBracketMode();
@@ -1232,95 +1326,8 @@ const paperTradingPlugin = {
1232
1326
  if (userDataStreamMode !== 'off') {
1233
1327
  logger.info(TAG, `User-data stream enabled in mode=${userDataStreamMode}`);
1234
1328
  }
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
- }
1329
+ // (Ingest + approval wiring is hoisted above the mode branch — F10:
1330
+ // it must exist on PAPER boots too, so a runtime live-flip finds it.)
1324
1331
  // Venue-dispatched construction (multi-venue Phase 0). For 'binance'
1325
1332
  // this is a pure pass-through to `new LiveAdapter(...)` — identical
1326
1333
  // args, identical behavior; the unsupported-venue arm is unreachable
@@ -1334,6 +1341,10 @@ const paperTradingPlugin = {
1334
1341
  credentials: hlCredentials,
1335
1342
  mode: tradingMode,
1336
1343
  marketSlippagePct: readPluginConfig().hl?.marketSlippagePct,
1344
+ microLive: microLiveConfig,
1345
+ // F26: same wiring object as the Binance arm — the SIGTERM
1346
+ // drain covers both venues because it drains this client.
1347
+ tradeIngest,
1337
1348
  },
1338
1349
  }
1339
1350
  : {
@@ -1350,31 +1361,9 @@ const paperTradingPlugin = {
1350
1361
  ],
1351
1362
  });
1352
1363
  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
- });
1364
+ // Journal close-bypass cleanup shared installer (see its definition
1365
+ // above; the runtime re-installs it on every reconnect-built adapter).
1366
+ installDriftCleanup(liveAdapter);
1378
1367
  // Post-registration async init (fire-and-forget — register must be sync)
1379
1368
  liveAdapter.initialize().then(async () => {
1380
1369
  // After init, run a one-shot startup reconciliation between
@@ -1556,6 +1545,24 @@ const paperTradingPlugin = {
1556
1545
  });
1557
1546
  };
1558
1547
  wireWatcherJournalCapture(bootstrapWatcher);
1548
+ // Journal capture for the paper take-profit leg. Same contract as the
1549
+ // watcher wiring above: a target close bypasses close_position, so without
1550
+ // this it would leave a phantom-open journal row (the issue #199 class).
1551
+ // Wired once — the simulator instance outlives adapter swaps (every
1552
+ // PaperAdapter is constructed around this same object), unlike watchers.
1553
+ simulator.on('target_closed', (ev) => {
1554
+ onStopWatcherClose(autoCapture, {
1555
+ symbol: ev.symbol,
1556
+ leg: 'exchange_target',
1557
+ // The breached level and the price that breached it — the assessment
1558
+ // records both, so a gap past the target stays visible in the journal.
1559
+ stopPrice: ev.targetPrice,
1560
+ markPrice: ev.markPrice,
1561
+ order: ev.order,
1562
+ }).catch((err) => {
1563
+ logger.warn(TAG, `take-profit close capture failed: ${formatError(err)}`);
1564
+ });
1565
+ });
1559
1566
  const runtime = new PluginRuntime({
1560
1567
  adapter,
1561
1568
  mode: tradingMode,
@@ -1564,6 +1571,16 @@ const paperTradingPlugin = {
1564
1571
  marketFeed,
1565
1572
  operationLock: wave9OperationLock,
1566
1573
  onWatcherCreated: wireWatcherJournalCapture,
1574
+ // F9: reconnect-built adapters get the SAME boot wiring — drift-cleanup
1575
+ // listener + audit-trail ingest client + journal auto-capture — instead
1576
+ // of silently reverting to bare adapters until the next restart.
1577
+ onAdapterCreated: installDriftCleanup,
1578
+ liveWiring: {
1579
+ tradeIngestBase: tradeIngest
1580
+ ? { client: tradeIngest.client, userId: tradeIngest.userId }
1581
+ : undefined,
1582
+ autoCapture,
1583
+ },
1567
1584
  });
1568
1585
  // Periodic DB-vs-exchange sweep (issues #199/#203): the boot-only reconcile
1569
1586
  // left phantom-open journal rows alive for days between restarts. Runs for
@@ -1798,40 +1815,43 @@ const paperTradingPlugin = {
1798
1815
  // If the ledger failed to load, Wave 9 admission is disabled and generic
1799
1816
  // watcher behavior still starts normally here.
1800
1817
  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;
1818
+ // Approved-order listener lifecycle (F10): follows ADAPTER SWAPS, not boot
1819
+ // state. A PAPER-boot box that goes live from the dashboard starts the
1820
+ // listener at the flip; a live→PAPER flip stops it (previously it stayed
1821
+ // wired to the ORPHANED live adapter — an operator approval would have
1822
+ // fired a real exchange order while the dashboard said PAPER); a
1823
+ // credential swap rebinds it to the fresh adapter. Started only after
1824
+ // runtime, the shared mutation lock, and durable Wave 9 ownership state
1825
+ // are fully resolved — same ordering the boot-only version had.
1826
+ const approvalLifecycle = new ApprovalListenerLifecycle({
1827
+ resolveApprovalMode,
1828
+ hasWiring: () => proposalManagerCtx !== undefined && proposalListenerCredentials !== undefined,
1829
+ buildListener: (boundAdapter) => {
1830
+ let approvalCfg;
1809
1831
  let cfgForUrl;
1810
1832
  try {
1811
1833
  cfgForUrl = readPluginConfig();
1834
+ approvalCfg = cfgForUrl.approval;
1812
1835
  }
1813
1836
  catch {
1814
1837
  cfgForUrl = undefined;
1815
1838
  }
1816
- const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForUrl);
1817
- proposalDecisionListener = new ProposalDecisionListener({
1818
- baseUrl: ingestBaseUrl,
1839
+ return new ProposalDecisionListener({
1840
+ baseUrl: getUserDataStreamIngestBaseUrl(cfgForUrl),
1819
1841
  ingestToken: proposalListenerCredentials.ingestToken,
1820
1842
  userId: proposalListenerCredentials.userId,
1821
- adapter: runtime.adapter,
1843
+ adapter: boundAdapter,
1822
1844
  binanceApi,
1823
1845
  autoCapture,
1824
1846
  operationLock: wave9OperationLock,
1825
1847
  checkWave9LiveSymbolOwnership,
1826
1848
  pollIntervalMs: approvalCfg?.pollIntervalMs ?? 3_000,
1827
1849
  });
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
- }
1850
+ },
1851
+ });
1852
+ runtime.setOnAdapterSwapped((a) => { void approvalLifecycle.onAdapterSwapped(a); });
1853
+ // Boot application — the same path every later swap takes.
1854
+ void approvalLifecycle.onAdapterSwapped(runtime.adapter);
1835
1855
  const wave9ActivationClient = new IntelWave9ActivationClient(intelApiDeps);
1836
1856
  const getWave9AccountSnapshot = async () => {
1837
1857
  if (!runtime.adapter.isLive) {
@@ -2040,7 +2060,7 @@ const paperTradingPlugin = {
2040
2060
  installSignalHandlers({
2041
2061
  runtime,
2042
2062
  tradeStore: tradeIngest?.client,
2043
- proposalListener: proposalDecisionListener,
2063
+ proposalListener: approvalLifecycle,
2044
2064
  stateManager,
2045
2065
  });
2046
2066
  // Warm the trading params cache (fire-and-forget — register must be sync)
@@ -2137,7 +2157,10 @@ const paperTradingPlugin = {
2137
2157
  autoCapture,
2138
2158
  proposalManager: proposalManagerCtx?.manager,
2139
2159
  userId: proposalManagerCtx?.userId,
2140
- approvalMode: approvalModeForTool,
2160
+ // Per-call read (F10): a config approval.mode change applies on
2161
+ // the NEXT create_order, not the next restart. The deps literal
2162
+ // is rebuilt per execute, so this re-resolves every call.
2163
+ approvalMode: resolveApprovalMode(),
2141
2164
  wave9AdmissionGuard,
2142
2165
  wave9ActivationCheck: () => wave9ActivationClient.getActivation(),
2143
2166
  refreshPaperState: reloadState,
@@ -2615,16 +2638,26 @@ const paperTradingPlugin = {
2615
2638
  {
2616
2639
  name: 'set_exchange_credentials',
2617
2640
  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.',
2641
+ 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
2642
  parameters: TOOL_PARAMS.set_exchange_credentials,
2620
- execute: async (_id, params) => jsonResult(await setExchangeCredentialsTool(params, { runtime, adapterDeps })),
2643
+ execute: async (_id, params) => {
2644
+ const prov = verifyOperatorProvenance(params.operator_token);
2645
+ if (!prov.ok)
2646
+ return jsonResult({ error: prov.error });
2647
+ return jsonResult(await setExchangeCredentialsTool(params, { runtime, adapterDeps }));
2648
+ },
2621
2649
  },
2622
2650
  {
2623
2651
  name: 'set_trading_mode',
2624
2652
  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.',
2653
+ description: 'Operator-only. Move the plugin between PAPER / MICRO_LIVE / LIVE. Enforces the one-rung-at-a-time ladder. Refused without dashboard operator provenance.',
2626
2654
  parameters: TOOL_PARAMS.set_trading_mode,
2627
- execute: async (_id, params) => jsonResult(await setTradingModeTool(params, { runtime, adapterDeps })),
2655
+ execute: async (_id, params) => {
2656
+ const prov = verifyOperatorProvenance(params.operator_token);
2657
+ if (!prov.ok)
2658
+ return jsonResult({ error: prov.error });
2659
+ return jsonResult(await setTradingModeTool(params, { runtime, adapterDeps }));
2660
+ },
2628
2661
  },
2629
2662
  {
2630
2663
  name: 'test_exchange_credentials',
@@ -2636,9 +2669,14 @@ const paperTradingPlugin = {
2636
2669
  {
2637
2670
  name: 'clear_exchange_credentials',
2638
2671
  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.',
2672
+ 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
2673
  parameters: TOOL_PARAMS.clear_exchange_credentials,
2641
- execute: async (_id, params) => jsonResult(await clearExchangeCredentialsTool(params, { runtime, adapterDeps })),
2674
+ execute: async (_id, params) => {
2675
+ const prov = verifyOperatorProvenance(params.operator_token);
2676
+ if (!prov.ok)
2677
+ return jsonResult({ error: prov.error });
2678
+ return jsonResult(await clearExchangeCredentialsTool(params, { runtime, adapterDeps }));
2679
+ },
2642
2680
  },
2643
2681
  {
2644
2682
  name: 'get_bracket_config',
@@ -2650,9 +2688,14 @@ const paperTradingPlugin = {
2650
2688
  {
2651
2689
  name: 'set_bracket_requirement',
2652
2690
  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.',
2691
+ 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
2692
  parameters: TOOL_PARAMS.set_bracket_requirement,
2655
- execute: async (_id, params) => jsonResult(setBracketRequirementTool(params)),
2693
+ execute: async (_id, params) => {
2694
+ const prov = verifyOperatorProvenance(params.operator_token);
2695
+ if (!prov.ok)
2696
+ return jsonResult({ error: prov.error });
2697
+ return jsonResult(setBracketRequirementTool(params));
2698
+ },
2656
2699
  },
2657
2700
  ];
2658
2701
  // ---- Tool gate (central per-user config, slice 1) ----
@@ -0,0 +1,11 @@
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
+ /** Test-only — drop the singleton so a fresh histogram is created. */
11
+ export declare function __resetEventLoopMonitorForTests(): void;