dexterm-cli 0.2.0 → 0.2.1

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 (138) hide show
  1. package/dist/packages/agents/src/spectator.js +28 -2
  2. package/dist/packages/agents/src/tiers.js +3 -0
  3. package/dist/packages/agents/src/tools.js +3 -0
  4. package/dist/packages/autotrade/src/backtest.js +6 -2
  5. package/dist/packages/autotrade/src/fleet.js +2 -2
  6. package/dist/packages/autotrade/src/index.js +2 -0
  7. package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
  8. package/dist/packages/autotrade/src/rugproof.js +231 -0
  9. package/dist/packages/autotrade/src/runner.js +132 -17
  10. package/dist/packages/autotrade/src/service-install.js +6 -4
  11. package/dist/packages/autotrade/src/sessions.js +16 -7
  12. package/dist/packages/autotrade/src/strategy-stats.js +344 -0
  13. package/dist/packages/autotrade/src/strategy.js +12 -1
  14. package/dist/packages/classifier/src/calibrate.js +138 -0
  15. package/dist/packages/classifier/src/classify.js +107 -0
  16. package/dist/packages/classifier/src/config.js +220 -0
  17. package/dist/packages/classifier/src/dossier.js +95 -0
  18. package/dist/packages/classifier/src/hardware.js +63 -0
  19. package/dist/packages/classifier/src/index.js +18 -0
  20. package/dist/packages/classifier/src/models.js +111 -0
  21. package/dist/packages/classifier/src/providers/heuristic.js +77 -0
  22. package/dist/packages/classifier/src/providers/http.js +63 -0
  23. package/dist/packages/classifier/src/providers/jev-open.js +86 -0
  24. package/dist/packages/classifier/src/providers/llm.js +89 -0
  25. package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
  26. package/dist/packages/classifier/src/questions.js +97 -0
  27. package/dist/packages/classifier/src/registry.js +42 -0
  28. package/dist/packages/classifier/src/snapshot.js +31 -0
  29. package/dist/packages/classifier/src/systemone.js +88 -0
  30. package/dist/packages/classifier/src/types.js +18 -0
  31. package/dist/packages/cli/assets/dash/dash.css +139 -0
  32. package/dist/packages/cli/assets/dash/dash.js +200 -0
  33. package/dist/packages/cli/assets/dash/icon.svg +1 -0
  34. package/dist/packages/cli/assets/dash/index.html +36 -0
  35. package/dist/packages/cli/src/app-help-gen.js +12 -0
  36. package/dist/packages/cli/src/autotrade-cmd/fleet.js +9 -2
  37. package/dist/packages/cli/src/autotrade-cmd/run.js +11 -1
  38. package/dist/packages/cli/src/autotrade-cmd/signal.js +2 -2
  39. package/dist/packages/cli/src/classifier-cmd.js +353 -0
  40. package/dist/packages/cli/src/classifier-live.js +81 -0
  41. package/dist/packages/cli/src/dash/data.js +217 -0
  42. package/dist/packages/cli/src/dash/server.js +92 -0
  43. package/dist/packages/cli/src/dash-cmd.js +113 -0
  44. package/dist/packages/cli/src/doctor.js +34 -0
  45. package/dist/packages/cli/src/live/screens/token-live.js +4 -0
  46. package/dist/packages/cli/src/live/signals.js +8 -0
  47. package/dist/packages/cli/src/main.js +98 -6
  48. package/dist/packages/cli/src/mcp-follow.js +21 -1
  49. package/dist/packages/cli/src/narratives-cmd.js +147 -0
  50. package/dist/packages/cli/src/narratives.js +114 -0
  51. package/dist/packages/cli/src/overlay/model.js +147 -0
  52. package/dist/packages/cli/src/overlay/page.js +146 -0
  53. package/dist/packages/cli/src/overlay/pnl.js +75 -0
  54. package/dist/packages/cli/src/overlay/server.js +170 -0
  55. package/dist/packages/cli/src/overlay-cmd.js +367 -0
  56. package/dist/packages/cli/src/record-cmd.js +82 -11
  57. package/dist/packages/cli/src/rules-cmd.js +2 -1
  58. package/dist/packages/cli/src/rules.js +24 -4
  59. package/dist/packages/cli/src/screens/narrative.js +154 -2
  60. package/dist/packages/cli/src/state.js +1 -0
  61. package/dist/packages/cli/src/version.js +1 -1
  62. package/dist/packages/custom/src/rules.js +5 -1
  63. package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
  64. package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
  65. package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
  66. package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
  67. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
  68. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +3 -1
  69. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
  70. package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
  71. package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
  72. package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
  73. package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
  74. package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
  75. package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
  76. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  77. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
  78. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +2 -0
  79. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +2 -0
  80. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +34 -0
  81. package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
  85. package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
  87. package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
  88. package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
  90. package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
  92. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  93. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
  94. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
  95. package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
  96. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
  97. package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
  98. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
  99. package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
  101. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
  105. package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
  107. package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
  109. package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
  110. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
  111. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
  112. package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
  113. package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
  114. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
  115. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +2 -0
  116. package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
  117. package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
  118. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
  119. package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
  120. package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
  121. package/dist/packages/mcp/src/server.js +104 -0
  122. package/dist/packages/mcp/src/spectator-sink.js +27 -0
  123. package/dist/packages/narratives/src/cluster.js +193 -0
  124. package/dist/packages/narratives/src/embed.js +184 -0
  125. package/dist/packages/narratives/src/export.js +60 -0
  126. package/dist/packages/narratives/src/index.js +10 -0
  127. package/dist/packages/narratives/src/label.js +48 -0
  128. package/dist/packages/narratives/src/normalize.js +179 -0
  129. package/dist/packages/narratives/src/recorder.js +93 -0
  130. package/dist/packages/narratives/src/tracker.js +328 -0
  131. package/dist/packages/recorder/src/exit-probes.js +215 -0
  132. package/dist/packages/recorder/src/index.js +1 -0
  133. package/dist/packages/recorder/src/recorder.js +11 -1
  134. package/dist/packages/recorder/src/retention.js +4 -0
  135. package/dist/packages/recorder/src/schema.js +20 -1
  136. package/dist/packages/tui/src/keymap.js +5 -0
  137. package/package.json +3 -1
  138. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
@@ -0,0 +1,231 @@
1
+ // dexterm autotrade — rug-proofing (task-1748): who can pull the pool, and a fast exit when it starts to go. PAPER ONLY.
2
+ //
3
+ // The control's two 2026-10-26 losses ($NOTHING, $AMERICA) and the three on 10-27 were creator LP pulls on hand-made
4
+ // PumpSwap pools: WSOL as the pool's BASE mint, launchpad unknown, the mint not ending in `pump`, the creator holding the
5
+ // LP tokens (FINDINGS-2026-10-27 §1-2). A pull is ONE transaction with no build-up: every post-entry signal (the pool's
6
+ // SOL vault, a Jupiter quote, DexScreener liquidity) fires only after it. So the PRIMARY defense is pre-entry and
7
+ // structural; the drain guard below only shortens the time a position sits in a pool that is being emptied and makes
8
+ // the book honest. A same-block creator pull cannot be front-run by anyone reading the chain.
9
+ //
10
+ // Pre-entry gates, cheapest first, fail closed (a gate that cannot be evaluated refuses the coin with `-unknown`):
11
+ // pool-age pool (pair) age >= minPoolAgeMin. Token age is a free lower bound first (a token younger
12
+ // than the minimum has no older pool); the pair's own creation time decides. tick, pair
13
+ // holders top-10 / dev share under the strategy's safety values (unknown = refuse) tick
14
+ // liquidity-recheck two liquidity reads >= afterSec apart: both >= floorX x the exit floor, the newer
15
+ // not more than maxDropPct under the older history
16
+ // pool-structure the pool is a pump.fun migration (PumpSwap, token = base, WSOL = quote) or a pool
17
+ // whose LP is verifiably burned / locked; WSOL as the BASE mint = hand-made: refuse pair
18
+ // lp-locked PumpSwap pool account -> lp mint: supply 0 (burned) passes; supply > 0 with the
19
+ // largest holder the pool creator (or any wallet over maxHolderLpPct) refuses RPC
20
+ // withdraw-history no `Withdraw` instruction among the pool's newest transactions (bounded scan) RPC
21
+ // sell-route a Jupiter token -> SOL quote at routeX x the position size returns a route Jupiter
22
+ // Every result has a named rule (`rugproof:<gate>[-why]`) that lands in the ledger; time gates (age, recheck) say
23
+ // when to look again instead of skipping the coin for good.
24
+ //
25
+ // Post-entry (drainVerdict): exit the whole position at once on liquidity / reserve down > liqDropPct from the entry
26
+ // read, a reserve near 0, or ANY Jupiter quote error (NO_ROUTES_FOUND, CANNOT_COMPUTE_…). A rate limit or a network
27
+ // failure is not a quote error (no exit on it; it is counted). Young or partly-verified pools get a smaller size
28
+ // (youngPoolSizeCapUsd).
29
+ //
30
+ // Pure: no I/O. The probes (DexScreener pair, PumpSwap pool account, LP mint, signatures, Jupiter) are in rugproof-probe.ts.
31
+ export const WSOL_MINT = 'So11111111111111111111111111111111111111112';
32
+ export const PUMPSWAP_PROGRAM = 'pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA';
33
+ export const RUGPROOF_DEFAULTS = {
34
+ minPoolAgeMin: 10,
35
+ requireSafePool: true,
36
+ maxHolderLpPct: 10,
37
+ maxWithdraws: 0,
38
+ withdrawScanTx: 25,
39
+ liquidityRecheck: { afterSec: 60, floorX: 2, maxDropPct: 15 },
40
+ routeX: 2,
41
+ youngPool: { underMin: 30, maxSizePct: 50 },
42
+ drainGuard: { pollSec: 10, liqDropPct: 25, exitOnNoRoute: true, minReserveSol: 0.5 },
43
+ };
44
+ export const GATE_ORDER = ['pool-age', 'holders', 'liquidity-recheck', 'pool-structure', 'lp-locked', 'withdraw-history', 'sell-route'];
45
+ const pass = (gate, detail, partial = false) => ({ gate, pass: true, rule: `rugproof:${gate}`, detail, ...(partial ? { partial: true } : {}) });
46
+ const fail = (gate, why, detail, retryAfterMs) => ({ gate, pass: false, rule: `rugproof:${gate}${why ? `-${why}` : ''}`, detail, ...(retryAfterMs !== undefined ? { retryAfterMs: Math.max(1000, Math.round(retryAfterMs)) } : {}) });
47
+ const m = (ms) => `${(ms / 60e3).toFixed(1)} min`;
48
+ const usd = (x) => `$${Math.round(x).toLocaleString('en-US')}`;
49
+ /** A pump.fun mint (vanity suffix) or Jupiter's launchpad tag says pump.fun. */
50
+ export const isPumpMint = (mint, launchpad) => mint.endsWith('pump') || launchpad === 'pump' || launchpad === 'pump.fun';
51
+ // ───────────────────────────── free gates (tick + history) ─────────────────────────────
52
+ /** Token age is a lower bound on pool age; the pair's creation time (when known) decides. */
53
+ export function gatePoolAge(r, k, pairCreatedAt, now = k.t) {
54
+ const min = r.minPoolAgeMin * 60e3;
55
+ if (k.createdAt !== null && now - k.createdAt < min)
56
+ return fail('pool-age', null, `token age ${m(now - k.createdAt)} < ${r.minPoolAgeMin} min (no older pool can exist)`, min - (now - k.createdAt));
57
+ if (pairCreatedAt === undefined)
58
+ return pass('pool-age', 'token age ok; pool age not read yet', true);
59
+ if (pairCreatedAt === null)
60
+ return fail('pool-age', 'unknown', 'the pool has no creation time');
61
+ const age = now - pairCreatedAt;
62
+ if (age < min)
63
+ return fail('pool-age', null, `pool age ${m(age)} < ${r.minPoolAgeMin} min`, min - age);
64
+ return pass('pool-age', `pool age ${m(age)}`);
65
+ }
66
+ /**
67
+ * The strategy's own holder values (entry.safety, the GUARDRAILS numbers), with its own unknown rules: an unknown top-10
68
+ * share refuses unless unknownHolders is allow; an unknown dev share passes, as the engine's screen has always done (the
69
+ * holder sources rarely report it: refusing it would refuse nearly every coin), and is marked partial.
70
+ */
71
+ export function gateHolders(k, maxTop10Pct, maxDevPct, unknownHolders = 'reject') {
72
+ let partial = false;
73
+ if (maxTop10Pct !== null) {
74
+ if (k.top10 === null) {
75
+ if (unknownHolders === 'reject')
76
+ return fail('holders', 'unknown', 'top-10 share unknown');
77
+ partial = true;
78
+ }
79
+ else if (k.top10 > maxTop10Pct)
80
+ return fail('holders', 'top10', `top 10 hold ${k.top10.toFixed(1)}% > ${maxTop10Pct}%`);
81
+ }
82
+ if (maxDevPct !== null && k.dev !== null && k.dev > maxDevPct)
83
+ return fail('holders', 'dev', `dev holds ${k.dev.toFixed(1)}% > ${maxDevPct}%`);
84
+ return pass('holders', `top10 ${k.top10 ?? '?'}%, dev ${k.dev ?? 'unknown'}%`, partial);
85
+ }
86
+ /**
87
+ * Two liquidity reads at least afterSec apart (the newest and the newest one at or before now - afterSec, from the
88
+ * coin's own tick history): both >= floorX x the exit floor, the newer not more than maxDropPct under the older.
89
+ */
90
+ export function gateLiquidityRecheck(r, history, k, exitFloorUsd) {
91
+ const rc = r.liquidityRecheck;
92
+ if (!rc)
93
+ return pass('liquidity-recheck', 'not required');
94
+ const floor = rc.floorX * exitFloorUsd;
95
+ const liqNow = k.liq;
96
+ if (liqNow === null)
97
+ return fail('liquidity-recheck', 'unknown', 'no liquidity read');
98
+ if (liqNow < floor)
99
+ return fail('liquidity-recheck', 'floor', `liquidity ${usd(liqNow)} < ${rc.floorX}x the ${usd(exitFloorUsd)} exit floor`);
100
+ const cutoff = k.t - rc.afterSec * 1000;
101
+ let older = null;
102
+ for (const h of history)
103
+ if (h.mint === k.mint && h.t <= cutoff && h.liq !== null && (!older || h.t > older.t))
104
+ older = h;
105
+ if (!older) {
106
+ const first = history.find((h) => h.mint === k.mint && h.liq !== null);
107
+ const wait = first ? first.t + rc.afterSec * 1000 - k.t : rc.afterSec * 1000;
108
+ return fail('liquidity-recheck', 'pending', `no read ${rc.afterSec} s before this one yet`, wait > 0 ? wait : rc.afterSec * 1000);
109
+ }
110
+ if (older.liq < floor)
111
+ return fail('liquidity-recheck', 'floor', `liquidity ${usd(older.liq)} ${Math.round((k.t - older.t) / 1000)} s ago < ${rc.floorX}x the exit floor`);
112
+ const drop = (older.liq - liqNow) / older.liq * 100;
113
+ if (drop > rc.maxDropPct)
114
+ return fail('liquidity-recheck', 'falling', `liquidity ${usd(older.liq)} -> ${usd(liqNow)} in ${Math.round((k.t - older.t) / 1000)} s (-${drop.toFixed(1)}% > ${rc.maxDropPct}%)`);
115
+ return pass('liquidity-recheck', `${usd(older.liq)} -> ${usd(liqNow)} over ${Math.round((k.t - older.t) / 1000)} s`);
116
+ }
117
+ /** Owners that hold LP tokens without being able to withdraw them (burn / incinerator). Locks are not trusted by name. */
118
+ export const BURN_OWNERS = new Set(['1nc1nerator11111111111111111111111111111111', '11111111111111111111111111111111']);
119
+ export function gatePoolStructure(r, mint, launchpad, pair) {
120
+ if (!r.requireSafePool)
121
+ return pass('pool-structure', 'not required');
122
+ if (!pair)
123
+ return fail('pool-structure', 'unknown', 'no pool listed for the coin');
124
+ if (pair.baseMint === WSOL_MINT)
125
+ return fail('pool-structure', 'wsol-base', `WSOL is the pool's base mint (${pair.dex} ${pair.address.slice(0, 8)}…): a hand-made pool, not a pump.fun migration`);
126
+ if (pair.baseMint !== mint)
127
+ return fail('pool-structure', 'not-base', `the coin is not the pool's base mint (${pair.dex})`);
128
+ if (pair.quoteMint !== WSOL_MINT)
129
+ return fail('pool-structure', 'quote', `the pool's quote is not WSOL (${pair.quoteMint.slice(0, 8)}…)`);
130
+ const dex = pair.dex.toLowerCase();
131
+ if (dex === 'pumpswap') {
132
+ return isPumpMint(mint, launchpad)
133
+ ? pass('pool-structure', 'pump.fun migration shape (PumpSwap, token = base, WSOL = quote)')
134
+ : pass('pool-structure', `PumpSwap, token = base, WSOL = quote, launchpad ${launchpad ?? 'unknown'}: LP must be verified`, true);
135
+ }
136
+ // Other venues (Raydium, Meteora, …): the LP lock cannot be verified here, so it is refused (fail closed).
137
+ return fail('pool-structure', 'unverifiable', `${pair.dex} pool: its LP lock cannot be verified by dexterm (only PumpSwap pools are read)`);
138
+ }
139
+ export function gateLpLocked(r, pair, lp, mint) {
140
+ if (!r.requireSafePool)
141
+ return pass('lp-locked', 'not required');
142
+ if (lp === 'error' || lp === null)
143
+ return fail('lp-locked', 'unknown', `the PumpSwap pool account ${pair.address.slice(0, 8)}… could not be read`);
144
+ if (lp.baseMint === WSOL_MINT)
145
+ return fail('lp-locked', 'wsol-base', 'the pool account says WSOL is the base mint (hand-made pool)');
146
+ if (lp.baseMint !== mint || lp.quoteMint !== WSOL_MINT)
147
+ return fail('lp-locked', 'mismatch', 'the pool account does not match the listed pair');
148
+ if (lp.lpSupply === 0n)
149
+ return pass('lp-locked', 'LP supply 0: every LP token is burned, nobody can withdraw');
150
+ const live = lp.holders.filter((h) => !BURN_OWNERS.has(h.owner) && h.amount > 0n);
151
+ const share = (a) => Number((a * 1000000n) / lp.lpSupply) / 10_000;
152
+ const byCreator = live.find((h) => h.owner === lp.creator);
153
+ if (byCreator)
154
+ return fail('lp-locked', 'creator', `the pool creator ${lp.creator.slice(0, 8)}… holds ${share(byCreator.amount).toFixed(2)}% of the LP: it can withdraw at any time`);
155
+ const top = live[0];
156
+ if (top && share(top.amount) > r.maxHolderLpPct)
157
+ return fail('lp-locked', 'holder', `one wallet ${top.owner.slice(0, 8)}… holds ${share(top.amount).toFixed(2)}% of the LP (> ${r.maxHolderLpPct}%)`);
158
+ if (!lp.holders.length)
159
+ return fail('lp-locked', 'unknown', 'LP supply > 0 but the holder list could not be read');
160
+ return pass('lp-locked', `LP supply > 0, largest live holder ${top ? share(top.amount).toFixed(2) : 0}%`, true);
161
+ }
162
+ export function gateWithdrawHistory(r, scan, lp) {
163
+ if (lp && lp.lpSupply === 0n && r.maxWithdraws === 0 && scan === null)
164
+ return pass('withdraw-history', 'LP burned: a withdraw is impossible');
165
+ if (scan === 'error' || scan === null)
166
+ return fail('withdraw-history', 'unknown', 'the pool\'s transactions could not be read');
167
+ if (scan.byCreator > 0)
168
+ return fail('withdraw-history', 'creator', `${scan.byCreator} creator Withdraw among the pool's newest ${scan.scanned} transactions`);
169
+ if (scan.withdraws > r.maxWithdraws)
170
+ return fail('withdraw-history', null, `${scan.withdraws} Withdraw among the pool's newest ${scan.scanned} transactions (max ${r.maxWithdraws})`);
171
+ return pass('withdraw-history', `no Withdraw among the newest ${scan.scanned} transactions`, scan.scanned < Math.min(5, r.withdrawScanTx));
172
+ }
173
+ export function gateSellRoute(r, route, sizeUsd) {
174
+ if (r.routeX === null)
175
+ return pass('sell-route', 'not required');
176
+ if (!route)
177
+ return fail('sell-route', 'unknown', 'no sell quote');
178
+ if (!route.ok)
179
+ return fail('sell-route', route.kind === 'no-route' ? 'none' : 'unknown', `Jupiter token -> SOL at ${r.routeX}x the ${usd(sizeUsd)} size: ${route.error}`);
180
+ return pass('sell-route', `Jupiter routes ${r.routeX}x the size (impact ${route.impactPct ?? '?'}%)`);
181
+ }
182
+ export function verdictOf(r, results, poolAgeMs) {
183
+ const refused = results.find((x) => !x.pass) ?? null;
184
+ const partial = results.some((x) => x.partial);
185
+ const young = r.youngPool && (poolAgeMs === null || poolAgeMs < r.youngPool.underMin * 60e3);
186
+ return { pass: !refused, results, refused, sizeCapPct: !refused && r.youngPool && (partial || young) ? r.youngPool.maxSizePct : null };
187
+ }
188
+ /** Exit the whole position at once on a reserve near 0, a reserve / liquidity drop > liqDropPct, or a quote error. */
189
+ export function drainVerdict(g, base, read) {
190
+ const ratio = base.reserveSol && read.reserveSol !== null ? read.reserveSol / base.reserveSol : null;
191
+ const estLiq = ratio !== null && base.liqUsd !== null ? base.liqUsd * ratio : read.liqUsd;
192
+ if (read.reserveSol !== null && read.reserveSol < g.minReserveSol)
193
+ return { exit: true, reason: 'drain-reserve', detail: `pool SOL reserve ${read.reserveSol.toFixed(3)} < ${g.minReserveSol} SOL`, liqUsd: estLiq ?? 0 };
194
+ if (ratio !== null && (1 - ratio) * 100 > g.liqDropPct)
195
+ return { exit: true, reason: 'drain-liquidity', detail: `pool SOL reserve ${base.reserveSol.toFixed(2)} -> ${read.reserveSol.toFixed(2)} (-${((1 - ratio) * 100).toFixed(1)}% > ${g.liqDropPct}%)`, liqUsd: estLiq };
196
+ if (base.liqUsd && read.liqUsd !== null && (1 - read.liqUsd / base.liqUsd) * 100 > g.liqDropPct)
197
+ return { exit: true, reason: 'drain-liquidity', detail: `liquidity ${usd(base.liqUsd)} -> ${usd(read.liqUsd)} (-${((1 - read.liqUsd / base.liqUsd) * 100).toFixed(1)}% > ${g.liqDropPct}% since entry)`, liqUsd: read.liqUsd };
198
+ if (g.exitOnNoRoute && read.route && !read.route.ok && read.route.kind === 'no-route')
199
+ return { exit: true, reason: 'drain-no-route', detail: `Jupiter has no sell route: ${read.route.error}`, liqUsd: estLiq ?? 0 };
200
+ if (read.route && !read.route.ok)
201
+ return { exit: false, note: `quote ${read.route.kind}: ${read.route.error}` };
202
+ return { exit: false };
203
+ }
204
+ /** The size a young or partly-verified pool may take: maxSizePct of what the strategy picked. */
205
+ export function youngPoolSizeCapUsd(v, usdPicked) {
206
+ return v.sizeCapPct === null ? null : (usdPicked * v.sizeCapPct) / 100;
207
+ }
208
+ // ───────────────────────────── offline (replays, backtests) ─────────────────────────────
209
+ /**
210
+ * The gates a recorded tape can evaluate: pool age (the tape's createdAt: the token's, else the pair's), holders, the
211
+ * second liquidity read (the coin's own recorded rows; the recorder's ~2-min cadence makes afterSec at least one row
212
+ * gap), and the pool-structure PROXY: a pump.fun mint (`…pump` / launchpad pump) passes, anything else is refused as
213
+ * `rugproof:pool-structure-offline` (the tape has no pool account, LP mint or transactions). Used by backtest() for a
214
+ * rug-proof strategy and by the control's replay.
215
+ */
216
+ export function rugproofTapeVet(r) {
217
+ return async (k, ctx) => {
218
+ const res = [
219
+ gatePoolAge(r, k, k.createdAt, k.t),
220
+ gateHolders(k, ctx?.maxTop10Pct ?? null, ctx?.maxDevPct ?? null, ctx?.unknownHolders),
221
+ // the structural proxy is free on a tape: it goes before the second read (a refusal for good beats a wait)
222
+ !r.requireSafePool || isPumpMint(k.mint, k.launchpad) ? pass('pool-structure', 'pump.fun mint (offline proxy)') : fail('pool-structure', 'offline', `not a pump.fun mint (launchpad ${k.launchpad}): the tape cannot verify its LP`),
223
+ gateLiquidityRecheck(r, ctx?.history ?? [], k, ctx?.exitFloorUsd ?? 2000),
224
+ ];
225
+ const bad = res.find((x) => !x.pass);
226
+ if (bad)
227
+ return { ok: false, rule: bad.rule, detail: bad.detail, ...(bad.retryAfterMs !== undefined ? { retryAfterMs: bad.retryAfterMs } : {}) };
228
+ const v = verdictOf(r, res, k.createdAt !== null ? k.t - k.createdAt : null);
229
+ return { ok: true, recheck: true, ...(v.sizeCapPct !== null ? { sizeCapPct: v.sizeCapPct } : {}) };
230
+ };
231
+ }
@@ -34,6 +34,8 @@ export function simExecutor(x) {
34
34
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
35
35
  const REVIEW = ['time_review', 'time_tighten'];
36
36
  const INFO_ONLY = ['mechanical_exit', 'stuck'];
37
+ /** Session ends that fail the same way every time: no new session for the rest of the run. */
38
+ const GUARD_REASONS = new Set(['account-mismatch', 'not-tool-less', 'account-unavailable']);
37
39
  async function* fromArray(ticks) { for (const k of ticks)
38
40
  yield k; }
39
41
  export async function runAutotrade(o) {
@@ -121,27 +123,105 @@ export async function runAutotrade(o) {
121
123
  const count = (rule) => { refusals[rule] = (refusals[rule] ?? 0) + 1; };
122
124
  const refuse = (rule, data) => { count(rule); ledger.add(now, 'refused', { rule, ...data }); };
123
125
  // A vet that throws or hangs is a check that could not run: the strategy's `unknown` decides (reject by default).
124
- const vetCoin = async (k) => {
126
+ const vetCoin = async (k, ctx) => {
125
127
  let r;
126
128
  try {
127
- r = await o.vet(k);
129
+ r = await o.vet(k, ctx);
128
130
  }
129
131
  catch (e) {
130
132
  r = { ok: false, rule: 'vet-error', detail: e.message.slice(0, 160) };
131
133
  }
132
134
  if (r.ok) {
133
- vetted.add(k.mint);
135
+ if (!r.recheck)
136
+ vetted.add(k.mint);
134
137
  if (r.note)
135
- ledger.add(now, 'vetted', { mint: k.mint, note: r.note.slice(0, 200) });
138
+ ledger.add(now, 'vetted', { mint: k.mint, note: r.note.slice(0, 200), ...(r.sizeCapPct !== undefined ? { sizeCapPct: r.sizeCapPct } : {}) });
136
139
  }
137
140
  return r;
138
141
  };
142
+ const vetCtx = (k, sizeUsd, h) => ({ history: h, sizeUsd, exitFloorUsd: x.minExitLiquidityUsd, maxTop10Pct: s.entry.safety.maxTop10Pct, maxDevPct: s.entry.safety.maxDevPct, unknownHolders: s.entry.safety.unknownHolders });
143
+ const drainGuard = s.entry.safety.rugproof?.drainGuard ?? null;
144
+ const stuckSince = new Map();
145
+ /** The liquidity read each position was bought at (the drain guard's base). */
146
+ const entryLiq = new Map();
147
+ // Paper only: a real-signal bag is still in the user's wallet, so it keeps its exit signals and carries to the next
148
+ // real run (validateStrategy also refuses the option outside mode paper).
149
+ const writeOffMin = !o.real && s.mode === 'paper' ? x.stuckWriteOffMin ?? null : null;
150
+ /** Positions the pool probe saw drained (no feed-priced retry while it still says so). */
151
+ const drained = new Set();
152
+ /**
153
+ * task-1748, every step, for every open position:
154
+ * - the drain guard: an exit verdict from the pool poller (SOL vault / Jupiter, <= 10 s apart), or this coin's own
155
+ * feed liquidity down more than drainGuard.liqDropPct from the entry read, sells the whole position at once. The
156
+ * sale is priced on the liquidity the guard saw (0 when the route is gone), not on a lagging feed row: an
157
+ * emptied pool leaves nothing to sell into, the bag goes stuck and the write-off below closes it.
158
+ * - execution.stuckWriteOffMin (paper): a bag stuck that long is closed at its realizable value (sells already
159
+ * made kept, the rest 0), with a written_off event and a trade_closed line, so crash recovery never adopts it.
160
+ */
161
+ const sweepOpen = async (tick) => {
162
+ for (const p of [...book.open.values()]) {
163
+ if (p.status !== 'open')
164
+ continue;
165
+ const k = latest.get(p.mint) ?? p.lastTick;
166
+ if (drainGuard && !p.stuck) {
167
+ const v = o.drain?.verdict(p.id) ?? null;
168
+ const base = entryLiq.has(p.id) ? entryLiq.get(p.id) ?? null : (p.liqWindow[0]?.liq || null);
169
+ const own = tick && tick.mint === p.mint && base && tick.liq !== null && (1 - tick.liq / base) * 100 > drainGuard.liqDropPct
170
+ ? { reason: 'drain-liquidity', detail: `liquidity $${Math.round(base)} -> $${Math.round(tick.liq)} since entry (-${((1 - tick.liq / base) * 100).toFixed(1)}% > ${drainGuard.liqDropPct}%)`, liqUsd: tick.liq } : null;
171
+ const hit = v ?? own;
172
+ if (hit) {
173
+ ledger.add(now, 'drain_guard', { positionId: p.id, mint: p.mint, reason: hit.reason, detail: hit.detail, liqUsd: hit.liqUsd, source: v ? 'pool-probe' : 'feed' });
174
+ say(`DRAIN GUARD ${p.symbol}: ${hit.detail}`);
175
+ await sellPosition(p, { ...k, t: now, liq: hit.liqUsd ?? 0 }, p.tokens, hit.reason, hit.detail);
176
+ // The probe saw the pool emptied or the route gone: the lagging feed must not "sell" the bag at its old
177
+ // liquidity. While the probe still says so, the monitor's stuck retry is off for this position.
178
+ if (v && p.status === 'open' && p.stuck)
179
+ drained.add(p.id);
180
+ }
181
+ }
182
+ const since = stuckSince.get(p.id);
183
+ if (writeOffMin !== null && p.status === 'open' && p.stuck && since !== undefined && now - since >= writeOffMin * 60e3) {
184
+ const ct = book.close(p, now, 'written-off', true);
185
+ o.drain?.unwatch(p.id);
186
+ stuckSince.delete(p.id);
187
+ ledger.add(now, 'written_off', { positionId: p.id, mint: p.mint, stuckMin: Math.round((now - since) / 6e3) / 10, realizedUsd: ct.proceedsUsd, costUsd: ct.costUsd, why: `stuck ${writeOffMin} min under the exit floor: closed at realizable value (the rest counts 0)` });
188
+ ledger.add(now, 'trade_closed', { trade: { ...ct, fills: undefined }, entryPrice: p.entryPrice, peakPrice: p.peakPrice, troughPrice: p.troughPrice ?? null, writtenOff: true });
189
+ say(`WRITTEN OFF ${p.symbol}: stuck ${writeOffMin} min, ${ct.pnlPct}% realized`);
190
+ }
191
+ if (p.status === 'closed') {
192
+ const c = byPosition.get(p.id);
193
+ if (c) {
194
+ if (c.inflight)
195
+ await c.inflight;
196
+ await closeCandidate(c, 'position-closed');
197
+ }
198
+ }
199
+ }
200
+ };
139
201
  const liveCandidateSessions = () => [...cands.values()].filter((q) => q.session && !q.positionId).length;
140
202
  const everyLive = () => [...new Set([...cands.values(), ...byPosition.values()])];
141
203
  const newCand = (k, matched, session, kind, positionId) => ({
142
204
  mint: k.mint, symbol: k.symbol, since: now, lastMatch: now, matched, session, sessionKind: kind, lastSent: view(k), lastSentT: now, seq: 0,
143
205
  inflight: null, pendingText: [], notes: [], closing: false, positionId, awaitingReview: null, oneShot: kind === 'position', costSeen: 0, turns: 0, lastDecision: null,
144
206
  });
207
+ /**
208
+ * Why the session's process ended on its own (null while alive). A session that proved the account or its tool list
209
+ * wrong (or had no account) blocks new sessions for the run; the guard is logged once, by the session that set it.
210
+ * Checked on every path that learns of a death: a null reply AND a close (a candidate's TTL can close a session that
211
+ * already died, before its null reply is read; task-1712).
212
+ */
213
+ let guardLogged = false;
214
+ const sessionDied = (c) => {
215
+ const died = c.session?.endReason?.() ?? null;
216
+ if (c.session && died && GUARD_REASONS.has(died)) {
217
+ if (!guardLogged && (gate.blocked === null || gate.blockedBy === c.session.id)) {
218
+ guardLogged = true;
219
+ ledger.add(now, 'guard', { state: died, effect: 'no new sessions this run; mechanical exits continue', sessionId: c.session.id });
220
+ }
221
+ gate.block(died, c.session.id);
222
+ }
223
+ return died;
224
+ };
145
225
  const closeCandidate = async (c, reason) => {
146
226
  if (c.closing)
147
227
  return;
@@ -163,8 +243,11 @@ export async function runAutotrade(o) {
163
243
  c.costSeen = st.costUsd;
164
244
  }
165
245
  sessionCost += st.costUsd;
166
- ledger.add(now, 'session_end', { sessionId: c.session.id, sessionKind: c.sessionKind, mint: c.mint, reason, turns: st.turns, costUsd: st.costUsd });
167
- say(`session ${c.session.id} ended (${reason}) · ${st.turns} turns · $${st.costUsd.toFixed(4)}`);
246
+ // A session that had already failed its proof ended for that reason, whatever closed its candidate.
247
+ const died = sessionDied(c);
248
+ const why = died && GUARD_REASONS.has(died) ? died : reason;
249
+ ledger.add(now, 'session_end', { sessionId: c.session.id, sessionKind: c.sessionKind, mint: c.mint, reason: why, turns: st.turns, costUsd: st.costUsd });
250
+ say(`session ${c.session.id} ended (${why}) · ${st.turns} turns · $${st.costUsd.toFixed(4)}`);
168
251
  }
169
252
  };
170
253
  const sellPosition = async (p, k, tokens, reason, detail) => {
@@ -174,21 +257,37 @@ export async function runAutotrade(o) {
174
257
  if ('refused' in f) {
175
258
  if (!p.stuck) {
176
259
  p.stuck = true;
260
+ stuckSince.set(p.id, now);
177
261
  ledger.add(now, 'stuck', { positionId: p.id, mint: p.mint, why: f.refused, reason });
178
262
  say(`STUCK ${p.symbol}: cannot sell (${f.refused})`);
179
263
  }
180
264
  return;
181
265
  }
182
266
  p.stuck = false;
267
+ stuckSince.delete(p.id);
183
268
  ledger.add(now, 'fill', { positionId: p.id, mint: p.mint, ...fillMcaps(fillView(f), k), detail, ...sigTag(f) });
184
269
  say(`SELL ${p.symbol} ${reason}: $${f.usd.toFixed(2)} at ${lvl(f.price, k)} (impact ${f.impactPct}%) — ${detail}`);
185
270
  const ct = book.sell(p, f);
271
+ if (ct)
272
+ o.drain?.unwatch(p.id);
186
273
  if (ct) {
187
274
  // task-1530: the price extremes the monitor saw while held (the trade ledger's MFE / MAE)
188
275
  ledger.add(now, 'trade_closed', { trade: { ...ct, fills: undefined }, entryPrice: p.entryPrice, peakPrice: p.peakPrice, troughPrice: p.troughPrice ?? null, ...(o.real ? { tag: 'real-signal' } : {}) });
189
276
  say(`CLOSED ${ct.symbol}: ${ct.pnlUsd >= 0 ? '+' : ''}$${ct.pnlUsd.toFixed(2)} (${ct.pnlPct}%) by ${ct.exitReason}`);
190
277
  }
191
278
  };
279
+ /** task-1748: a young or partly verified pool takes at most the vet's sizeCapPct of the strategy's pick. */
280
+ const capYoungPool = (r, c) => {
281
+ if (!r.ok || c.sizeCapPct === undefined)
282
+ return r;
283
+ const capUsd = Math.round(r.usd * c.sizeCapPct) / 100;
284
+ if (capUsd >= r.usd)
285
+ return r;
286
+ if (capUsd < s.sizing.minUsd)
287
+ return { ok: false, refused: 'below-min-after-rugproof-young-pool', clamps: [...r.clamps, { rule: 'rugproof-young-pool', fromUsd: r.usd, toUsd: capUsd }] };
288
+ const f = r.usd ? capUsd / r.usd : 1;
289
+ return { ...r, usd: capUsd, firstTrancheUsd: Math.round(r.firstTrancheUsd * f * 100) / 100, clamps: [...r.clamps, { rule: 'rugproof-young-pool', fromUsd: r.usd, toUsd: capUsd }] };
290
+ };
192
291
  /** Apply a validated decision at the current virtual time. */
193
292
  const apply = async (c, d) => {
194
293
  const k = latest.get(c.mint);
@@ -213,7 +312,7 @@ export async function runAutotrade(o) {
213
312
  return;
214
313
  }
215
314
  const req = { profile: d.profile, sizePct: d.sizePct, sizeUsd: d.sizeUsd, confidence: d.confidence };
216
- const r = clampEntry(book, k, req, killed);
315
+ const r = capYoungPool(clampEntry(book, k, req, killed), c);
217
316
  ledger.add(now, 'clamp', { mint: c.mint, sessionId: c.session?.id ?? null, request: req, result: r.ok ? { ok: true, usd: r.usd, firstTrancheUsd: r.firstTrancheUsd, profile: r.profile.id, basis: r.basis, clamps: r.clamps } : r });
218
317
  for (const cl of r.clamps)
219
318
  clampCount[cl.rule] = (clampCount[cl.rule] ?? 0) + 1;
@@ -356,14 +455,9 @@ export async function runAutotrade(o) {
356
455
  c.costSeen = Math.max(c.costSeen, r.costUsd);
357
456
  }
358
457
  if (!r || !r.decision.ok) {
359
- const died = c.session.endReason?.() ?? null;
458
+ const died = sessionDied(c);
360
459
  if (!r)
361
460
  ledger.add(now, 'session_timeout', { sessionId: c.session.id, mint: c.mint, ended: died });
362
- if (died === 'account-mismatch' || died === 'not-tool-less' || died === 'account-unavailable') {
363
- if (!gate.blocked)
364
- ledger.add(now, 'guard', { state: died, effect: 'no new sessions this run; mechanical exits continue', sessionId: c.session.id });
365
- gate.block(died);
366
- }
367
461
  if (review)
368
462
  await noAnswer(review, !r ? (died ?? 'timeout') : 'invalid-decision');
369
463
  if (!r) {
@@ -477,6 +571,8 @@ export async function runAutotrade(o) {
477
571
  ledger.add(now, 'fill', { positionId: p.id, mint: p.mint, profile: prof.id, ...fillMcaps(fillView(f), k), bookUsd, ...(q.ctx?.proposalId ? { proposalId: q.ctx.proposalId } : {}), ...sigTag(f) });
478
572
  say(`BUY ${k.symbol} [${prof.id}]: $${f.usd.toFixed(2)} at ${lvl(f.price, k)}${f.partial ? ' (PARTIAL: impact cap)' : ''} · liq $${Math.round(k.liq ?? 0)}`);
479
573
  const c = cands.get(q.mint);
574
+ entryLiq.set(p.id, k.liq ?? null);
575
+ o.drain?.watch({ positionId: p.id, mint: p.mint, entryLiqUsd: k.liq ?? null, poolAddress: c?.pool?.address ?? null, quoteVault: c?.pool?.quoteVault ?? null, tokens: () => p.tokens });
480
576
  if (c) {
481
577
  c.positionId = p.id;
482
578
  byPosition.set(p.id, c);
@@ -617,6 +713,7 @@ export async function runAutotrade(o) {
617
713
  ledger.add(now, 'halt', { reason: book.state.halted });
618
714
  o.approvals?.cancelAll(`halted:${book.state.halted}`);
619
715
  }
716
+ await sweepOpen(k);
620
717
  // Held coin → the monitor (mechanical first, then wakes).
621
718
  const pos = book.open.get(k.mint);
622
719
  const chatSell = o.approvals?.takeExit?.(k.mint) ?? null;
@@ -646,7 +743,11 @@ export async function runAutotrade(o) {
646
743
  }
647
744
  }
648
745
  }
746
+ else if (pos && drained.has(pos.id) && o.drain?.verdict(pos.id)) {
747
+ // task-1748: still drained per the pool probe: nothing sells into an emptied pool (the write-off closes it).
748
+ }
649
749
  else if (pos) {
750
+ drained.delete(pos.id);
650
751
  const prof0 = s.exits.profiles[pos.profileId];
651
752
  // task-1654: a real run's (or a paper scope's) maxHoldMinutes limit tightens the profile's holding cap (never loosens it).
652
753
  const holdCap = o.real?.maxHoldMinutes?.() ?? o.maxHoldMinutes?.() ?? null;
@@ -707,18 +808,26 @@ export async function runAutotrade(o) {
707
808
  if (sr.pass) {
708
809
  // Cheap pre-check so a full book does not spawn sessions it could never act on.
709
810
  const pre = clampEntry(book, k, {}, killed);
710
- const v = pre.ok && o.vet && !vetted.has(k.mint) ? await vetCoin(k) : null;
811
+ const v = pre.ok && o.vet && !vetted.has(k.mint) ? await vetCoin(k, vetCtx(k, pre.usd, h)) : null;
711
812
  if (!pre.ok) {
712
813
  if (!['already-open'].includes(pre.refused))
713
814
  count(`precheck:${pre.refused}`);
714
815
  }
715
816
  else if (v && !v.ok) {
716
- refuse(v.rule, { mint: k.mint, symbol: k.symbol, stage: 'vet', detail: v.detail });
717
- skippedUntil.set(k.mint, Infinity);
718
- say(`SKIP ${k.symbol} (${k.mint.slice(0, 4)}…): ${v.rule} (${v.detail})`);
817
+ // task-1748: a time gate (pool age, the second liquidity read) looks again later; any other refusal is for good.
818
+ const retry = v.retryAfterMs !== undefined;
819
+ refuse(v.rule, { mint: k.mint, symbol: k.symbol, stage: 'vet', detail: v.detail, ...(retry ? { retryAfterMs: v.retryAfterMs } : {}) });
820
+ skippedUntil.set(k.mint, retry ? now + v.retryAfterMs : Infinity);
821
+ say(`SKIP ${k.symbol} (${k.mint.slice(0, 4)}…): ${v.rule} (${v.detail})${retry ? `; again in ${Math.round(v.retryAfterMs / 1000)} s` : ''}`);
719
822
  }
720
823
  else if (decideAuto) {
721
824
  const cand = newCand(k, sr.matched, null, 'candidate', null);
825
+ if (v?.ok) {
826
+ if (v.sizeCapPct !== undefined)
827
+ cand.sizeCapPct = v.sizeCapPct;
828
+ if (v.pool)
829
+ cand.pool = v.pool;
830
+ }
722
831
  cands.set(k.mint, cand);
723
832
  ledger.add(now, 'candidate', { mint: k.mint, symbol: k.symbol, matched: sr.matched, tick: view(k) });
724
833
  await apply(cand, { action: 'enter', reason: 'auto' });
@@ -734,6 +843,12 @@ export async function runAutotrade(o) {
734
843
  const session = o.sessions(sid, s, { kind: 'candidate', mint: k.mint });
735
844
  gate.opened('candidate');
736
845
  const cand = newCand(k, sr.matched, session, 'candidate', null);
846
+ if (v?.ok) {
847
+ if (v.sizeCapPct !== undefined)
848
+ cand.sizeCapPct = v.sizeCapPct;
849
+ if (v.pool)
850
+ cand.pool = v.pool;
851
+ }
737
852
  cands.set(k.mint, cand);
738
853
  ledger.add(now, 'candidate', { mint: k.mint, symbol: k.symbol, matched: sr.matched, tick: view(k) });
739
854
  ledger.add(now, 'session_start', { sessionId: sid, sessionKind: 'candidate', agent: session.kind, mint: k.mint, model: sessionModel(session, s), budgetUsd: gate.caps.budgetUsdPerSession });
@@ -19,7 +19,7 @@
19
19
  // of its path, so a test home never replaces (or removes) the real one. A non-default home is passed as `--home`.
20
20
  // Only PATH is copied into the job's environment (the service needs to find `claude`); nothing else, no secrets.
21
21
  import { createHash } from 'node:crypto';
22
- import { join } from 'node:path';
22
+ import { join, posix } from 'node:path';
23
23
  export const SERVICE_LABEL = 'xyz.dexterm.autotrade';
24
24
  export const INSTALL_MANIFEST_SCHEMA = 'dexterm-autotrade-service-install/1';
25
25
  /** The label for a DEXTERM_HOME: the default one, or one suffixed by a hash of the path. */
@@ -32,7 +32,9 @@ export function serviceLabel(dexHome, defaultHome) {
32
32
  export const ensureArgs = (s) => [s.nodePath, s.binPath, 'autotrade', 'service', 'ensure', '--quiet', ...(s.dexHome ? ['--home', s.dexHome] : [])];
33
33
  // ───────────────────────────── macOS ─────────────────────────────
34
34
  const xml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
35
- export const launchAgentPath = (s) => join(s.userHome, 'Library', 'LaunchAgents', `${s.label}.plist`);
35
+ // LaunchAgent and systemd paths are macOS / Linux paths whatever host computes the plan (task-1712: a Windows host
36
+ // joined them with `\`); the Windows task and the manifest live under the local dexHome and use the host's join.
37
+ export const launchAgentPath = (s) => posix.join(s.userHome, 'Library', 'LaunchAgents', `${s.label}.plist`);
36
38
  export function renderLaunchAgent(s) {
37
39
  const args = ensureArgs(s).map((a) => ` <string>${xml(a)}</string>`).join('\n');
38
40
  const env = s.path ? ` <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key>\n <string>${xml(s.path)}</string>\n </dict>\n` : '';
@@ -70,7 +72,7 @@ ${env} <key>StartInterval</key>
70
72
  }
71
73
  // ───────────────────────────── Linux (systemd user) ─────────────────────────────
72
74
  export const systemdUnit = (s) => s.label.replace(/^xyz\./, '').replace(/\./g, '-');
73
- export const systemdDir = (s) => join(s.userHome, '.config', 'systemd', 'user');
75
+ export const systemdDir = (s) => posix.join(s.userHome, '.config', 'systemd', 'user');
74
76
  /** systemd quoting: double quotes, backslash and quote escaped, `%` doubled (specifier expansion). */
75
77
  const sd = (a) => `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
76
78
  export function renderSystemdService(s) {
@@ -189,7 +191,7 @@ export function installPlan(s, dexHome) {
189
191
  const dir = systemdDir(s);
190
192
  return {
191
193
  platform: s.platform, label: s.label, name: `${unit}.timer`,
192
- files: [{ path: join(dir, `${unit}.service`), content: renderSystemdService(s) }, { path: join(dir, `${unit}.timer`), content: renderSystemdTimer(s) }],
194
+ files: [{ path: posix.join(dir, `${unit}.service`), content: renderSystemdService(s) }, { path: posix.join(dir, `${unit}.timer`), content: renderSystemdTimer(s) }],
193
195
  activate: [['systemctl', '--user', 'daemon-reload'], ['systemctl', '--user', 'enable', '--now', `${unit}.timer`]],
194
196
  deactivate: [['systemctl', '--user', 'disable', '--now', `${unit}.timer`]],
195
197
  notes: ['A systemd user timer runs while you are logged in; `loginctl enable-linger` (your choice) keeps it running after logout and from boot.'],
@@ -131,12 +131,10 @@ class ClaudeCliSession {
131
131
  tools: iso.tools, mcpServers: iso.mcpServers, plugins: iso.plugins, agents: iso.agents, skills: iso.skills, slashCommands: iso.slashCommands,
132
132
  };
133
133
  this.o.onInit?.(init);
134
- if (!this.o.account.expectApiKeySource.includes(init.apiKeySource)) {
135
- this.kill('account-mismatch');
136
- return;
137
- }
138
- if (init.tools !== 0 || init.mcpServers !== 0) {
139
- this.kill('not-tool-less');
134
+ const failed = !this.o.account.expectApiKeySource.includes(init.apiKeySource) ? 'account-mismatch' : init.tools !== 0 || init.mcpServers !== 0 ? 'not-tool-less' : null;
135
+ if (failed) {
136
+ this.o.onProofFailed?.(failed);
137
+ this.kill(failed);
140
138
  return;
141
139
  }
142
140
  continue;
@@ -229,6 +227,8 @@ export class SessionGate {
229
227
  pausedUntil = 0;
230
228
  /** Set when a session proved the account or the tool list wrong: no new session until the runner restarts. */
231
229
  blocked = null;
230
+ /** The session whose own failure set `blocked` (null when the block came from elsewhere, e.g. a sibling strategy). */
231
+ blockedBy = null;
232
232
  constructor(caps, guard, opts = {}) {
233
233
  this.caps = caps;
234
234
  this.guard = guard;
@@ -312,7 +312,12 @@ export class SessionGate {
312
312
  /** Are new sessions paused by the allowance guard right now (wall clock)? */
313
313
  paused() { return this.clock() < this.pausedUntil; }
314
314
  /** A misconfigured account (or a CLI that reports tools) fails the same way every time: stop spawning for this run. */
315
- block(reason) { this.blocked ??= reason; }
315
+ block(reason, sessionId) {
316
+ if (this.blocked !== null)
317
+ return;
318
+ this.blocked = reason;
319
+ this.blockedBy = sessionId ?? null;
320
+ }
316
321
  }
317
322
  const isRec = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
318
323
  /** The MCP-shaped sections a replay can honestly fill from the tape row; everything else is named as unavailable. */
@@ -455,6 +460,7 @@ export function claudeSessionFactory(f) {
455
460
  catch (e) {
456
461
  failed = 'account-unavailable';
457
462
  f.log('session_account_error', { sessionId: id, error: e.message });
463
+ f.gate.block(failed, id);
458
464
  return null;
459
465
  }
460
466
  if (closed)
@@ -467,6 +473,9 @@ export function claudeSessionFactory(f) {
467
473
  onRateLimit: (r) => { f.gate.rateLimit(r); f.log('allowance', { sessionId: id, status: r.status, fiveHour: r.fiveHour, sevenDay: r.sevenDay, resetsAt: r.resetsAt }); },
468
474
  onLate: (text) => f.log('late_reply_ignored', { sessionId: id, why: 'arrived after decideWithinSec', text: text.slice(0, 400) }),
469
475
  onRaw: f.onRaw ? (line) => f.onRaw(id, line) : undefined,
476
+ // The gate closes at the failed proof itself, not when the runner later reads this session's (null) reply: a
477
+ // candidate that closes first (its TTL) must not leave the gate open for more sessions on the wrong account.
478
+ onProofFailed: (why) => f.gate.block(why, id),
470
479
  });
471
480
  return inner;
472
481
  })());