dexterm-cli 0.2.0 → 0.2.2

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 (157) 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/chat/connector.js +5 -4
  6. package/dist/packages/autotrade/src/chat/control.js +3 -0
  7. package/dist/packages/autotrade/src/chat/hub.js +37 -2
  8. package/dist/packages/autotrade/src/chat/index.js +3 -2
  9. package/dist/packages/autotrade/src/chat/readonly.js +530 -0
  10. package/dist/packages/autotrade/src/chat/service.js +12 -1
  11. package/dist/packages/autotrade/src/engine.js +75 -29
  12. package/dist/packages/autotrade/src/fleet.js +2 -2
  13. package/dist/packages/autotrade/src/index.js +4 -0
  14. package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
  15. package/dist/packages/autotrade/src/rugproof.js +231 -0
  16. package/dist/packages/autotrade/src/runner.js +203 -30
  17. package/dist/packages/autotrade/src/service-install.js +6 -4
  18. package/dist/packages/autotrade/src/sessions.js +16 -7
  19. package/dist/packages/autotrade/src/store.js +1 -1
  20. package/dist/packages/autotrade/src/strategy-stats.js +344 -0
  21. package/dist/packages/autotrade/src/strategy.js +29 -1
  22. package/dist/packages/autotrade/src/tiers.js +194 -0
  23. package/dist/packages/classifier/src/calibrate.js +138 -0
  24. package/dist/packages/classifier/src/classify.js +107 -0
  25. package/dist/packages/classifier/src/config.js +298 -0
  26. package/dist/packages/classifier/src/dossier.js +95 -0
  27. package/dist/packages/classifier/src/hardware.js +63 -0
  28. package/dist/packages/classifier/src/index.js +19 -0
  29. package/dist/packages/classifier/src/models.js +111 -0
  30. package/dist/packages/classifier/src/providers/heuristic.js +77 -0
  31. package/dist/packages/classifier/src/providers/http.js +63 -0
  32. package/dist/packages/classifier/src/providers/jev-open.js +86 -0
  33. package/dist/packages/classifier/src/providers/llm.js +89 -0
  34. package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
  35. package/dist/packages/classifier/src/questions.js +97 -0
  36. package/dist/packages/classifier/src/registry.js +42 -0
  37. package/dist/packages/classifier/src/snapshot.js +31 -0
  38. package/dist/packages/classifier/src/systemone.js +88 -0
  39. package/dist/packages/classifier/src/types.js +18 -0
  40. package/dist/packages/classifier/src/win-acl.js +71 -0
  41. package/dist/packages/cli/assets/dash/dash.css +139 -0
  42. package/dist/packages/cli/assets/dash/dash.js +200 -0
  43. package/dist/packages/cli/assets/dash/icon.svg +1 -0
  44. package/dist/packages/cli/assets/dash/index.html +36 -0
  45. package/dist/packages/cli/src/app-help-gen.js +13 -1
  46. package/dist/packages/cli/src/autotrade-cmd/fleet.js +12 -3
  47. package/dist/packages/cli/src/autotrade-cmd/run.js +14 -2
  48. package/dist/packages/cli/src/autotrade-cmd/service.js +3 -1
  49. package/dist/packages/cli/src/autotrade-cmd/signal.js +2 -2
  50. package/dist/packages/cli/src/autotrade-cmd/strategies.js +1 -1
  51. package/dist/packages/cli/src/autotrade-cmd/telegram-host.js +2 -0
  52. package/dist/packages/cli/src/classifier-cmd.js +361 -0
  53. package/dist/packages/cli/src/classifier-live.js +81 -0
  54. package/dist/packages/cli/src/dash/data.js +217 -0
  55. package/dist/packages/cli/src/dash/server.js +92 -0
  56. package/dist/packages/cli/src/dash-cmd.js +113 -0
  57. package/dist/packages/cli/src/doctor.js +34 -0
  58. package/dist/packages/cli/src/live/screens/token-live.js +4 -0
  59. package/dist/packages/cli/src/live/signals.js +8 -0
  60. package/dist/packages/cli/src/main.js +98 -6
  61. package/dist/packages/cli/src/mcp-follow.js +21 -1
  62. package/dist/packages/cli/src/narratives-cmd.js +147 -0
  63. package/dist/packages/cli/src/narratives.js +114 -0
  64. package/dist/packages/cli/src/overlay/model.js +147 -0
  65. package/dist/packages/cli/src/overlay/page.js +146 -0
  66. package/dist/packages/cli/src/overlay/pnl.js +75 -0
  67. package/dist/packages/cli/src/overlay/server.js +170 -0
  68. package/dist/packages/cli/src/overlay-cmd.js +367 -0
  69. package/dist/packages/cli/src/record-cmd.js +82 -11
  70. package/dist/packages/cli/src/rules-cmd.js +2 -1
  71. package/dist/packages/cli/src/rules.js +24 -4
  72. package/dist/packages/cli/src/screens/narrative.js +154 -2
  73. package/dist/packages/cli/src/skill-eval-build.js +7 -2
  74. package/dist/packages/cli/src/state.js +1 -0
  75. package/dist/packages/cli/src/version.js +1 -1
  76. package/dist/packages/custom/src/rules.js +5 -1
  77. package/dist/packages/mcp/assets/kb/app-help/autotrade-telegram.md +1 -1
  78. package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
  79. package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
  80. package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
  81. package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-confidence-tiers-experimental-unproven-paper-only.md +56 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +3 -1
  85. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
  87. package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
  88. package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
  90. package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
  92. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  93. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
  94. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +2 -0
  95. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +2 -0
  96. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +36 -0
  97. package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
  98. package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
  99. package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
  101. package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
  105. package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
  107. package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  109. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
  110. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
  111. package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
  112. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
  113. package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
  114. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
  115. package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
  116. package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
  117. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
  118. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
  119. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
  120. package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
  121. package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
  122. package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
  123. package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
  124. package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
  125. package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
  126. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
  127. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
  128. package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
  129. package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
  130. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
  131. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +2 -0
  132. package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
  133. package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
  134. package/dist/packages/mcp/assets/kb/docs/docs-telegram-read-only-data-check-top-and-signals-experimental.md +48 -0
  135. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +3 -1
  136. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-will-not-do.md +1 -1
  137. package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
  138. package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
  139. package/dist/packages/mcp/src/server.js +106 -1
  140. package/dist/packages/mcp/src/spectator-sink.js +27 -0
  141. package/dist/packages/narratives/src/cluster.js +193 -0
  142. package/dist/packages/narratives/src/embed.js +184 -0
  143. package/dist/packages/narratives/src/export.js +60 -0
  144. package/dist/packages/narratives/src/index.js +10 -0
  145. package/dist/packages/narratives/src/label.js +48 -0
  146. package/dist/packages/narratives/src/normalize.js +179 -0
  147. package/dist/packages/narratives/src/recorder.js +93 -0
  148. package/dist/packages/narratives/src/tracker.js +328 -0
  149. package/dist/packages/packs/src/skill-care.js +10 -2
  150. package/dist/packages/recorder/src/exit-probes.js +215 -0
  151. package/dist/packages/recorder/src/index.js +1 -0
  152. package/dist/packages/recorder/src/recorder.js +11 -1
  153. package/dist/packages/recorder/src/retention.js +4 -0
  154. package/dist/packages/recorder/src/schema.js +20 -1
  155. package/dist/packages/tui/src/keymap.js +5 -0
  156. package/package.json +3 -1
  157. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
@@ -21,12 +21,13 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, write
21
21
  import { uptime } from 'node:os';
22
22
  import { join } from 'node:path';
23
23
  import { Ledger, writeJsonAtomic } from "./ledger.js";
24
- import { EntryRules, SimBook, clampEntry, simBuy, simSell } from "./engine.js";
24
+ import { EntryRules, SimBook, clampEntry, simBuy, tierHardCapUsd, simSell } from "./engine.js";
25
25
  import { monitorTick } from "./monitor.js";
26
26
  import { buildDelta, renderDelta, renderEvent, urgent, view } from "./session.js";
27
27
  import { SessionGate, renderDossierRecord, renderPositionDossier, tapeDossierRecord } from "./sessions.js";
28
28
  import { DEFAULT_RUNNER_CONFIG, effectiveCaps } from "./runner-config.js";
29
29
  import { DEFAULT_PRICE_BASIS, fillMcaps, levelText } from "./basis-text.js";
30
+ import { confidenceTierLine, tierRecord } from "./tiers.js";
30
31
  export { Ledger } from "./ledger.js";
31
32
  export function simExecutor(x) {
32
33
  return { name: 'sim', buy: (k, usd) => simBuy(k, usd, x), sell: (k, tokens, reason) => simSell(k, tokens, x, reason) };
@@ -34,10 +35,16 @@ export function simExecutor(x) {
34
35
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
35
36
  const REVIEW = ['time_review', 'time_tighten'];
36
37
  const INFO_ONLY = ['mechanical_exit', 'stuck'];
38
+ /** Session ends that fail the same way every time: no new session for the rest of the run. */
39
+ const GUARD_REASONS = new Set(['account-mismatch', 'not-tool-less', 'account-unavailable']);
37
40
  async function* fromArray(ticks) { for (const k of ticks)
38
41
  yield k; }
39
42
  export async function runAutotrade(o) {
40
43
  const s = o.strategy;
44
+ // 0.2.2: EXPERIMENTAL confidence tiers run on paper only: never in a real-signal run, never in any mode but paper
45
+ // (validateStrategy refuses the block outside paper and the engine refuses it too; this is the runner's own gate).
46
+ if (s.sizing.tiers && !(!o.real && s.mode === 'paper'))
47
+ throw new Error(`strategy ${s.id}: confidence tiers (sizing.tiers) are EXPERIMENTAL and PAPER ONLY: refused ${o.real ? 'in a real-signal run' : `in mode "${s.mode}"`} (remove sizing.tiers to run it there)`);
41
48
  // task-1571: autotrade runs on paper only. dexterm holds no key and places no live order; live trading happens in the
42
49
  // Telegram trading bot of the user's choice.
43
50
  // task-1645: mode "real" = the same paper engine, plus a LIVE TRADE FIRE signal per fill for the user's own executor;
@@ -76,6 +83,8 @@ export async function runAutotrade(o) {
76
83
  const skippedUntil = new Map();
77
84
  /** Coins the vet passed this run (a refused one goes to skippedUntil for good). */
78
85
  const vetted = new Set();
86
+ /** 0.2.2 confidence tiers: coins whose T0 (skip) line was already written at the pre-check. */
87
+ const tierSkipSaid = new Set();
79
88
  const orders = [];
80
89
  const refusals = {};
81
90
  const clampCount = {};
@@ -95,6 +104,24 @@ export async function runAutotrade(o) {
95
104
  const pumps = new Set();
96
105
  /** Proposals waiting for an answer, by mint (task-1527). */
97
106
  const approvalsOpen = new Map();
107
+ /** task-2156: the first-tranche dollars of each open approval (an in-flight entry for the tier clamp). */
108
+ const approvalUsd = new Map();
109
+ /** task-2156: entries decided but not filled (pending open orders + open approvals), other than `except`'s own. */
110
+ const inFlight = (except) => {
111
+ let opens = 0;
112
+ let usd = 0;
113
+ for (const q of orders)
114
+ if (q.kind === 'open' && q.mint !== except) {
115
+ opens++;
116
+ usd += q.usd;
117
+ }
118
+ for (const m of approvalsOpen.keys())
119
+ if (m !== except) {
120
+ opens++;
121
+ usd += approvalUsd.get(m) ?? Infinity;
122
+ }
123
+ return { opens, usd };
124
+ };
98
125
  ledger.add(t0, 'run_start', { strategy: s.id, version: s.version, strategyHash: s.hash, mode: s.mode, ...(o.real ? { tag: 'real-signal', accountId: o.real.accountId } : {}), ...(s.highRisk ? { highRisk: true } : {}), tape: o.tape?.source ?? o.source.name, ticks: tapeTicks?.length ?? null, decide: decideAuto ? 'auto' : s.entry.decide, ...(o.approvals ? { approvals: o.approvals.required(s) ? 'required' : 'when-asked' } : {}), pacing: pacing.mode, executor: exec.name, caps: capsView(gate), ...(o.account ? { account: o.account.id } : {}) });
99
126
  // task-1645 r2: a real run takes over the real positions an earlier run of this strategy left open (held.json),
100
127
  // so their exits keep firing. A profile the strategy no longer has falls back to its default profile.
@@ -121,27 +148,105 @@ export async function runAutotrade(o) {
121
148
  const count = (rule) => { refusals[rule] = (refusals[rule] ?? 0) + 1; };
122
149
  const refuse = (rule, data) => { count(rule); ledger.add(now, 'refused', { rule, ...data }); };
123
150
  // 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) => {
151
+ const vetCoin = async (k, ctx) => {
125
152
  let r;
126
153
  try {
127
- r = await o.vet(k);
154
+ r = await o.vet(k, ctx);
128
155
  }
129
156
  catch (e) {
130
157
  r = { ok: false, rule: 'vet-error', detail: e.message.slice(0, 160) };
131
158
  }
132
159
  if (r.ok) {
133
- vetted.add(k.mint);
160
+ if (!r.recheck)
161
+ vetted.add(k.mint);
134
162
  if (r.note)
135
- ledger.add(now, 'vetted', { mint: k.mint, note: r.note.slice(0, 200) });
163
+ ledger.add(now, 'vetted', { mint: k.mint, note: r.note.slice(0, 200), ...(r.sizeCapPct !== undefined ? { sizeCapPct: r.sizeCapPct } : {}) });
136
164
  }
137
165
  return r;
138
166
  };
167
+ 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 });
168
+ const drainGuard = s.entry.safety.rugproof?.drainGuard ?? null;
169
+ const stuckSince = new Map();
170
+ /** The liquidity read each position was bought at (the drain guard's base). */
171
+ const entryLiq = new Map();
172
+ // Paper only: a real-signal bag is still in the user's wallet, so it keeps its exit signals and carries to the next
173
+ // real run (validateStrategy also refuses the option outside mode paper).
174
+ const writeOffMin = !o.real && s.mode === 'paper' ? x.stuckWriteOffMin ?? null : null;
175
+ /** Positions the pool probe saw drained (no feed-priced retry while it still says so). */
176
+ const drained = new Set();
177
+ /**
178
+ * task-1748, every step, for every open position:
179
+ * - the drain guard: an exit verdict from the pool poller (SOL vault / Jupiter, <= 10 s apart), or this coin's own
180
+ * feed liquidity down more than drainGuard.liqDropPct from the entry read, sells the whole position at once. The
181
+ * sale is priced on the liquidity the guard saw (0 when the route is gone), not on a lagging feed row: an
182
+ * emptied pool leaves nothing to sell into, the bag goes stuck and the write-off below closes it.
183
+ * - execution.stuckWriteOffMin (paper): a bag stuck that long is closed at its realizable value (sells already
184
+ * made kept, the rest 0), with a written_off event and a trade_closed line, so crash recovery never adopts it.
185
+ */
186
+ const sweepOpen = async (tick) => {
187
+ for (const p of [...book.open.values()]) {
188
+ if (p.status !== 'open')
189
+ continue;
190
+ const k = latest.get(p.mint) ?? p.lastTick;
191
+ if (drainGuard && !p.stuck) {
192
+ const v = o.drain?.verdict(p.id) ?? null;
193
+ const base = entryLiq.has(p.id) ? entryLiq.get(p.id) ?? null : (p.liqWindow[0]?.liq || null);
194
+ const own = tick && tick.mint === p.mint && base && tick.liq !== null && (1 - tick.liq / base) * 100 > drainGuard.liqDropPct
195
+ ? { 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;
196
+ const hit = v ?? own;
197
+ if (hit) {
198
+ 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' });
199
+ say(`DRAIN GUARD ${p.symbol}: ${hit.detail}`);
200
+ await sellPosition(p, { ...k, t: now, liq: hit.liqUsd ?? 0 }, p.tokens, hit.reason, hit.detail);
201
+ // The probe saw the pool emptied or the route gone: the lagging feed must not "sell" the bag at its old
202
+ // liquidity. While the probe still says so, the monitor's stuck retry is off for this position.
203
+ if (v && p.status === 'open' && p.stuck)
204
+ drained.add(p.id);
205
+ }
206
+ }
207
+ const since = stuckSince.get(p.id);
208
+ if (writeOffMin !== null && p.status === 'open' && p.stuck && since !== undefined && now - since >= writeOffMin * 60e3) {
209
+ const ct = book.close(p, now, 'written-off', true);
210
+ o.drain?.unwatch(p.id);
211
+ stuckSince.delete(p.id);
212
+ 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)` });
213
+ ledger.add(now, 'trade_closed', { trade: { ...ct, fills: undefined }, entryPrice: p.entryPrice, peakPrice: p.peakPrice, troughPrice: p.troughPrice ?? null, writtenOff: true });
214
+ say(`WRITTEN OFF ${p.symbol}: stuck ${writeOffMin} min, ${ct.pnlPct}% realized`);
215
+ }
216
+ if (p.status === 'closed') {
217
+ const c = byPosition.get(p.id);
218
+ if (c) {
219
+ if (c.inflight)
220
+ await c.inflight;
221
+ await closeCandidate(c, 'position-closed');
222
+ }
223
+ }
224
+ }
225
+ };
139
226
  const liveCandidateSessions = () => [...cands.values()].filter((q) => q.session && !q.positionId).length;
140
227
  const everyLive = () => [...new Set([...cands.values(), ...byPosition.values()])];
141
228
  const newCand = (k, matched, session, kind, positionId) => ({
142
229
  mint: k.mint, symbol: k.symbol, since: now, lastMatch: now, matched, session, sessionKind: kind, lastSent: view(k), lastSentT: now, seq: 0,
143
230
  inflight: null, pendingText: [], notes: [], closing: false, positionId, awaitingReview: null, oneShot: kind === 'position', costSeen: 0, turns: 0, lastDecision: null,
144
231
  });
232
+ /**
233
+ * Why the session's process ended on its own (null while alive). A session that proved the account or its tool list
234
+ * wrong (or had no account) blocks new sessions for the run; the guard is logged once, by the session that set it.
235
+ * Checked on every path that learns of a death: a null reply AND a close (a candidate's TTL can close a session that
236
+ * already died, before its null reply is read; task-1712).
237
+ */
238
+ let guardLogged = false;
239
+ const sessionDied = (c) => {
240
+ const died = c.session?.endReason?.() ?? null;
241
+ if (c.session && died && GUARD_REASONS.has(died)) {
242
+ if (!guardLogged && (gate.blocked === null || gate.blockedBy === c.session.id)) {
243
+ guardLogged = true;
244
+ ledger.add(now, 'guard', { state: died, effect: 'no new sessions this run; mechanical exits continue', sessionId: c.session.id });
245
+ }
246
+ gate.block(died, c.session.id);
247
+ }
248
+ return died;
249
+ };
145
250
  const closeCandidate = async (c, reason) => {
146
251
  if (c.closing)
147
252
  return;
@@ -163,8 +268,11 @@ export async function runAutotrade(o) {
163
268
  c.costSeen = st.costUsd;
164
269
  }
165
270
  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)}`);
271
+ // A session that had already failed its proof ended for that reason, whatever closed its candidate.
272
+ const died = sessionDied(c);
273
+ const why = died && GUARD_REASONS.has(died) ? died : reason;
274
+ ledger.add(now, 'session_end', { sessionId: c.session.id, sessionKind: c.sessionKind, mint: c.mint, reason: why, turns: st.turns, costUsd: st.costUsd });
275
+ say(`session ${c.session.id} ended (${why}) · ${st.turns} turns · $${st.costUsd.toFixed(4)}`);
168
276
  }
169
277
  };
170
278
  const sellPosition = async (p, k, tokens, reason, detail) => {
@@ -174,21 +282,37 @@ export async function runAutotrade(o) {
174
282
  if ('refused' in f) {
175
283
  if (!p.stuck) {
176
284
  p.stuck = true;
285
+ stuckSince.set(p.id, now);
177
286
  ledger.add(now, 'stuck', { positionId: p.id, mint: p.mint, why: f.refused, reason });
178
287
  say(`STUCK ${p.symbol}: cannot sell (${f.refused})`);
179
288
  }
180
289
  return;
181
290
  }
182
291
  p.stuck = false;
292
+ stuckSince.delete(p.id);
183
293
  ledger.add(now, 'fill', { positionId: p.id, mint: p.mint, ...fillMcaps(fillView(f), k), detail, ...sigTag(f) });
184
294
  say(`SELL ${p.symbol} ${reason}: $${f.usd.toFixed(2)} at ${lvl(f.price, k)} (impact ${f.impactPct}%) — ${detail}`);
185
295
  const ct = book.sell(p, f);
296
+ if (ct)
297
+ o.drain?.unwatch(p.id);
186
298
  if (ct) {
187
299
  // task-1530: the price extremes the monitor saw while held (the trade ledger's MFE / MAE)
188
300
  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
301
  say(`CLOSED ${ct.symbol}: ${ct.pnlUsd >= 0 ? '+' : ''}$${ct.pnlUsd.toFixed(2)} (${ct.pnlPct}%) by ${ct.exitReason}`);
190
302
  }
191
303
  };
304
+ /** task-1748: a young or partly verified pool takes at most the vet's sizeCapPct of the strategy's pick. */
305
+ const capYoungPool = (r, c) => {
306
+ if (!r.ok || c.sizeCapPct === undefined)
307
+ return r;
308
+ const capUsd = Math.round(r.usd * c.sizeCapPct) / 100;
309
+ if (capUsd >= r.usd)
310
+ return r;
311
+ if (capUsd < s.sizing.minUsd)
312
+ return { ok: false, refused: 'below-min-after-rugproof-young-pool', clamps: [...r.clamps, { rule: 'rugproof-young-pool', fromUsd: r.usd, toUsd: capUsd }] };
313
+ const f = r.usd ? capUsd / r.usd : 1;
314
+ return { ...r, usd: capUsd, firstTrancheUsd: Math.round(r.firstTrancheUsd * f * 100) / 100, clamps: [...r.clamps, { rule: 'rugproof-young-pool', fromUsd: r.usd, toUsd: capUsd }] };
315
+ };
192
316
  /** Apply a validated decision at the current virtual time. */
193
317
  const apply = async (c, d) => {
194
318
  const k = latest.get(c.mint);
@@ -213,8 +337,10 @@ export async function runAutotrade(o) {
213
337
  return;
214
338
  }
215
339
  const req = { profile: d.profile, sizePct: d.sizePct, sizeUsd: d.sizeUsd, confidence: d.confidence };
216
- const r = clampEntry(book, k, req, killed);
217
- 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 });
340
+ const r = capYoungPool(clampEntry(book, k, req, killed, now, inFlight(c.mint)), c);
341
+ 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.tier ? { ok: false, refused: r.refused, clamps: r.clamps } : r, ...(r.tier ? { tier: tierRecord(r.tier) } : {}) });
342
+ if (r.tier)
343
+ say(confidenceTierLine(r.tier, k, r.ok ? r.usd : undefined));
218
344
  for (const cl of r.clamps)
219
345
  clampCount[cl.rule] = (clampCount[cl.rule] ?? 0) + 1;
220
346
  if (!r.ok) {
@@ -299,7 +425,7 @@ export async function runAutotrade(o) {
299
425
  const t = latest.get(c.mint);
300
426
  if (!t)
301
427
  return { ok: false, refused: 'no-tick' };
302
- const rc = clampEntry(book, t, { profile: prof.id, sizeUsd: usd }, killed);
428
+ const rc = clampEntry(book, t, { profile: prof.id, sizeUsd: usd }, killed, now, inFlight(c.mint));
303
429
  return rc.ok ? { ok: true, usd: rc.usd, clamps: rc.clamps.map((q) => q.rule) } : { ok: false, refused: rc.refused };
304
430
  },
305
431
  blocked: entryBlocked,
@@ -320,19 +446,32 @@ export async function runAutotrade(o) {
320
446
  await closeCandidate(c, 'refused:approval-dropped');
321
447
  return;
322
448
  }
323
- const usd = Math.min(out.usd, r.usd);
324
- const scale = r.usd > 0 ? usd / r.usd : 0;
325
449
  const t = latest.get(c.mint) ?? k;
450
+ let usd = Math.min(out.usd, r.usd);
451
+ // task-2156: a tiered entry re-clamps at the approval (one open, concurrency, room, a fresh tick), since other
452
+ // entries may have been decided or filled while it waited.
453
+ if (s.sizing.tiers) {
454
+ const rc = clampEntry(book, t, { profile: prof.id, sizeUsd: usd }, killed, now, inFlight(c.mint));
455
+ if (!rc.ok) {
456
+ refuse('approval-dropped', { mint: c.mint, proposalId: out.proposalId, why: rc.refused });
457
+ if (!c.closing)
458
+ await closeCandidate(c, 'refused:approval-dropped');
459
+ return;
460
+ }
461
+ usd = Math.min(usd, rc.usd);
462
+ }
463
+ const scale = r.usd > 0 ? usd / r.usd : 0;
326
464
  ledger.add(now, 'approval_granted', { mint: c.mint, proposalId: out.proposalId, via: out.via, usd, proofId: out.proofId ? `${out.proofId.slice(0, 6)}…` : null });
327
465
  say(`APPROVED ${k.symbol}: $${round2(usd)} via ${out.via}`);
328
466
  const ladder = (s.sizing.ladder ?? []).slice(1).map((tr) => ({ frac: tr.frac, atPct: tr.atPct, deadline: now + tr.withinMin * 60e3, usd: usd * tr.frac }));
329
467
  const first = round2(r.firstTrancheUsd * scale);
330
468
  orders.push({ mint: c.mint, fillAt: now + x.latencyMs, decidedPrice: t.price ?? 0, usd: first, firstUsd: first, profile: prof.id, sessionId: c.session?.id ?? null, trigger: c.matched, ladder, kind: 'open', ctx: { proofId: out.proofId, proposalId: out.proposalId, orderHash: out.orderHash } });
331
469
  };
470
+ approvalUsd.set(c.mint, r.firstTrancheUsd);
332
471
  const pending = o.approvals.request(prop, live)
333
472
  .catch((e) => ({ approved: false, state: 'ERROR', why: e instanceof Error ? e.message.slice(0, 120) : String(e) }))
334
473
  .then(settle)
335
- .finally(() => { approvalsOpen.delete(c.mint); });
474
+ .finally(() => { approvalsOpen.delete(c.mint); approvalUsd.delete(c.mint); });
336
475
  approvalsOpen.set(c.mint, pending);
337
476
  // Lockstep (backtests, tests) waits for the answer; every other pacing lets it land when it lands.
338
477
  if (pacing.mode === 'lockstep')
@@ -356,14 +495,9 @@ export async function runAutotrade(o) {
356
495
  c.costSeen = Math.max(c.costSeen, r.costUsd);
357
496
  }
358
497
  if (!r || !r.decision.ok) {
359
- const died = c.session.endReason?.() ?? null;
498
+ const died = sessionDied(c);
360
499
  if (!r)
361
500
  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
501
  if (review)
368
502
  await noAnswer(review, !r ? (died ?? 'timeout') : 'invalid-decision');
369
503
  if (!r) {
@@ -437,11 +571,16 @@ export async function runAutotrade(o) {
437
571
  // Re-check at the fill, like a swap's slippage tolerance: a pool that drained or a price that ran
438
572
  // away between the decision and the fill refuses the order instead of buying into it.
439
573
  const prof0 = s.exits.profiles[q.profile];
440
- const why = (k.liq ?? 0) < prof0.fit.minLiquidityUsd ? 'fill-liquidity-gone'
441
- : k.price && q.decidedPrice && k.price > q.decidedPrice * (1 + x.slippageBps / 1e4) ? 'fill-slippage'
442
- // task-1538: on a shared paper account another strategy may have spent the cash since this order was decided;
443
- // the cash is held before the (async) fill so two strategies can never spend the same dollars.
444
- : book.account && !book.account.hold(q.usd) ? 'fill-account-cash' : null;
574
+ // task-2156: a tiered open re-checks one-open and concurrency at the fill (another entry may have filled first).
575
+ const tierBusy = s.sizing.tiers && q.kind === 'open'
576
+ ? (s.sizing.tiers.oneOpen !== false && book.open.size >= 1 ? 'fill-tier-one-open' : book.open.size >= s.risk.maxConcurrent ? 'fill-max-concurrent' : null)
577
+ : null;
578
+ const why = tierBusy ? tierBusy
579
+ : (k.liq ?? 0) < prof0.fit.minLiquidityUsd ? 'fill-liquidity-gone'
580
+ : k.price && q.decidedPrice && k.price > q.decidedPrice * (1 + x.slippageBps / 1e4) ? 'fill-slippage'
581
+ // task-1538: on a shared paper account another strategy may have spent the cash since this order was decided;
582
+ // the cash is held before the (async) fill so two strategies can never spend the same dollars.
583
+ : book.account && !book.account.hold(q.usd) ? 'fill-account-cash' : null;
445
584
  let f;
446
585
  try {
447
586
  f = why ? { refused: why } : await exec.buy(k, q.usd, q.ctx);
@@ -477,6 +616,8 @@ export async function runAutotrade(o) {
477
616
  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
617
  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
618
  const c = cands.get(q.mint);
619
+ entryLiq.set(p.id, k.liq ?? null);
620
+ 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
621
  if (c) {
481
622
  c.positionId = p.id;
482
623
  byPosition.set(p.id, c);
@@ -617,6 +758,7 @@ export async function runAutotrade(o) {
617
758
  ledger.add(now, 'halt', { reason: book.state.halted });
618
759
  o.approvals?.cancelAll(`halted:${book.state.halted}`);
619
760
  }
761
+ await sweepOpen(k);
620
762
  // Held coin → the monitor (mechanical first, then wakes).
621
763
  const pos = book.open.get(k.mint);
622
764
  const chatSell = o.approvals?.takeExit?.(k.mint) ?? null;
@@ -646,7 +788,11 @@ export async function runAutotrade(o) {
646
788
  }
647
789
  }
648
790
  }
791
+ else if (pos && drained.has(pos.id) && o.drain?.verdict(pos.id)) {
792
+ // task-1748: still drained per the pool probe: nothing sells into an emptied pool (the write-off closes it).
793
+ }
649
794
  else if (pos) {
795
+ drained.delete(pos.id);
650
796
  const prof0 = s.exits.profiles[pos.profileId];
651
797
  // task-1654: a real run's (or a paper scope's) maxHoldMinutes limit tightens the profile's holding cap (never loosens it).
652
798
  const holdCap = o.real?.maxHoldMinutes?.() ?? o.maxHoldMinutes?.() ?? null;
@@ -662,7 +808,16 @@ export async function runAutotrade(o) {
662
808
  else if (a.kind === 'add') {
663
809
  if (killed || book.state.halted)
664
810
  continue;
665
- const room = Math.min(a.usd, book.state.cashUsd, (book.bookEquity() * s.risk.maxExposurePct) / 100 - book.exposureUsd());
811
+ let room = Math.min(a.usd, book.state.cashUsd, (book.bookEquity() * s.risk.maxExposurePct) / 100 - book.exposureUsd());
812
+ // 0.2.2 confidence tiers: the whole position (every tranche) stays within the engine's tier ceiling (fails closed).
813
+ if (s.sizing.tiers) {
814
+ const left = tierHardCapUsd(book.bookEquity()) - pos.costUsd;
815
+ if (!(left >= s.sizing.minUsd)) {
816
+ refuse('ladder-add-tier-hard-cap', { positionId: pos.id });
817
+ continue;
818
+ }
819
+ room = Number.isFinite(room) ? Math.min(room, left) : 0;
820
+ }
666
821
  if (room < s.sizing.minUsd) {
667
822
  refuse('ladder-add-no-room', { positionId: pos.id });
668
823
  continue;
@@ -706,19 +861,31 @@ export async function runAutotrade(o) {
706
861
  const sr = rules.screen(k);
707
862
  if (sr.pass) {
708
863
  // Cheap pre-check so a full book does not spawn sessions it could never act on.
709
- const pre = clampEntry(book, k, {}, killed);
710
- const v = pre.ok && o.vet && !vetted.has(k.mint) ? await vetCoin(k) : null;
864
+ const pre = clampEntry(book, k, {}, killed, now, inFlight(k.mint));
865
+ const v = pre.ok && o.vet && !vetted.has(k.mint) ? await vetCoin(k, vetCtx(k, pre.usd, h)) : null;
711
866
  if (!pre.ok) {
712
867
  if (!['already-open'].includes(pre.refused))
713
868
  count(`precheck:${pre.refused}`);
869
+ if (pre.tier?.tier === 'T0' && !tierSkipSaid.has(k.mint)) {
870
+ tierSkipSaid.add(k.mint);
871
+ say(confidenceTierLine(pre.tier, k));
872
+ }
714
873
  }
715
874
  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})`);
875
+ // task-1748: a time gate (pool age, the second liquidity read) looks again later; any other refusal is for good.
876
+ const retry = v.retryAfterMs !== undefined;
877
+ refuse(v.rule, { mint: k.mint, symbol: k.symbol, stage: 'vet', detail: v.detail, ...(retry ? { retryAfterMs: v.retryAfterMs } : {}) });
878
+ skippedUntil.set(k.mint, retry ? now + v.retryAfterMs : Infinity);
879
+ say(`SKIP ${k.symbol} (${k.mint.slice(0, 4)}…): ${v.rule} (${v.detail})${retry ? `; again in ${Math.round(v.retryAfterMs / 1000)} s` : ''}`);
719
880
  }
720
881
  else if (decideAuto) {
721
882
  const cand = newCand(k, sr.matched, null, 'candidate', null);
883
+ if (v?.ok) {
884
+ if (v.sizeCapPct !== undefined)
885
+ cand.sizeCapPct = v.sizeCapPct;
886
+ if (v.pool)
887
+ cand.pool = v.pool;
888
+ }
722
889
  cands.set(k.mint, cand);
723
890
  ledger.add(now, 'candidate', { mint: k.mint, symbol: k.symbol, matched: sr.matched, tick: view(k) });
724
891
  await apply(cand, { action: 'enter', reason: 'auto' });
@@ -734,6 +901,12 @@ export async function runAutotrade(o) {
734
901
  const session = o.sessions(sid, s, { kind: 'candidate', mint: k.mint });
735
902
  gate.opened('candidate');
736
903
  const cand = newCand(k, sr.matched, session, 'candidate', null);
904
+ if (v?.ok) {
905
+ if (v.sizeCapPct !== undefined)
906
+ cand.sizeCapPct = v.sizeCapPct;
907
+ if (v.pool)
908
+ cand.pool = v.pool;
909
+ }
737
910
  cands.set(k.mint, cand);
738
911
  ledger.add(now, 'candidate', { mint: k.mint, symbol: k.symbol, matched: sr.matched, tick: view(k) });
739
912
  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
  })());
@@ -81,7 +81,7 @@ export class StrategyStore {
81
81
  return { id, source, ok: false, errors: r.errors, warnings: r.warnings };
82
82
  const s = r.strategy;
83
83
  return {
84
- id, source, ok: true, name: s.name, version: s.version, hash: s.hash, mode: s.mode, ...(s.highRisk ? { highRisk: true } : {}), profiles: Object.keys(s.exits.profiles), warnings: r.warnings,
84
+ id, source, ok: true, name: s.name, version: s.version, hash: s.hash, mode: s.mode, ...(s.highRisk ? { highRisk: true } : {}), ...(s.sizing.tiers ? { experimentalTiers: true } : {}), profiles: Object.keys(s.exits.profiles), warnings: r.warnings,
85
85
  ...(last[s.hash] ? { lastBacktest: last[s.hash] } : {}),
86
86
  };
87
87
  };