tradelab 1.1.0 → 1.2.0

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 (38) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +185 -388
  3. package/dist/cjs/index.cjs +31 -9
  4. package/dist/cjs/live.cjs +409 -7
  5. package/docs/README.md +32 -66
  6. package/docs/api-reference.md +269 -144
  7. package/docs/backtest-engine.md +167 -321
  8. package/docs/data-reporting-cli.md +114 -156
  9. package/docs/examples.md +6 -6
  10. package/docs/live-trading.md +254 -134
  11. package/docs/mcp.md +244 -23
  12. package/docs/research.md +99 -45
  13. package/examples/mcpLiveTrading.js +77 -0
  14. package/package.json +11 -3
  15. package/src/engine/optimize.js +25 -1
  16. package/src/engine/portfolio.js +4 -1
  17. package/src/live/dashboard/server.js +67 -8
  18. package/src/live/engine/paperEngine.js +5 -0
  19. package/src/live/index.js +2 -0
  20. package/src/live/session.js +402 -0
  21. package/src/mcp/liveTools.js +179 -0
  22. package/src/mcp/schemas.js +119 -0
  23. package/src/mcp/server.js +5 -1
  24. package/src/mcp/tools.js +125 -2
  25. package/templates/dashboard.html +595 -108
  26. package/types/index.d.ts +25 -0
  27. package/types/live.d.ts +99 -0
  28. package/types/mcp.d.ts +17 -0
  29. package/docs/superpowers/plans/2026-00-overview.md +0 -101
  30. package/docs/superpowers/plans/2026-01-metrics-correctness.md +0 -873
  31. package/docs/superpowers/plans/2026-02-indicator-library.md +0 -677
  32. package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +0 -882
  33. package/docs/superpowers/plans/2026-04-async-signals-seeding.md +0 -981
  34. package/docs/superpowers/plans/2026-05-mcp-server.md +0 -758
  35. package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +0 -508
  36. package/docs/superpowers/plans/2026-07-funding-carry-costs.md +0 -535
  37. package/docs/superpowers/plans/2026-08-live-dashboard.md +0 -547
  38. package/docs/superpowers/plans/HANDOFF.md +0 -88
@@ -148,6 +148,7 @@ function forceExitAll(runners, time) {
148
148
  export function backtestPortfolio({
149
149
  systems = [],
150
150
  equity = 10_000,
151
+ interval,
151
152
  allocation = "equal",
152
153
  collectEqSeries = true,
153
154
  collectReplay = false,
@@ -307,6 +308,7 @@ export function backtestPortfolio({
307
308
  const replay = combineReplay(systemResults, eqSeries, collectReplay);
308
309
  const allCandles = systems.flatMap((system) => system.candles || []);
309
310
  const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
311
+ const metricsInterval = interval ?? systems[0]?.interval;
310
312
  const metrics = buildMetrics({
311
313
  closed: trades,
312
314
  equityStart: equity,
@@ -314,11 +316,12 @@ export function backtestPortfolio({
314
316
  candles: orderedCandles,
315
317
  estBarMs: estimateBarMs(orderedCandles),
316
318
  eqSeries,
319
+ interval: metricsInterval,
317
320
  });
318
321
 
319
322
  return {
320
323
  symbol: "PORTFOLIO",
321
- interval: undefined,
324
+ interval: metricsInterval,
322
325
  range: undefined,
323
326
  trades,
324
327
  positions,
@@ -1,5 +1,5 @@
1
1
  import http from "node:http";
2
- import { readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
@@ -22,13 +22,26 @@ const FALLBACK_HTML = `<!doctype html>
22
22
  </body>
23
23
  </html>`;
24
24
 
25
- function readDashboardHtml() {
26
- if (import.meta.url) {
27
- const here = path.dirname(fileURLToPath(import.meta.url));
28
- const htmlPath = path.join(here, "..", "..", "..", "templates", "dashboard.html");
29
- return readFileSync(htmlPath, "utf8");
30
- }
25
+ function callerModuleDir() {
26
+ const stack = new Error().stack || "";
27
+ const lines = stack.split("\n").slice(1);
28
+ const match = lines
29
+ .map((line) => line.match(/(?:\()?(file:\/\/\/[^\s)]+|\/[^\s)]+):\d+:\d+/))
30
+ .find(Boolean);
31
+ if (!match) return process.cwd();
32
+ const filePath = match[1].startsWith("file://") ? fileURLToPath(match[1]) : match[1];
33
+ return path.dirname(filePath);
34
+ }
31
35
 
36
+ function readDashboardHtml() {
37
+ const here = callerModuleDir();
38
+ const candidates = [
39
+ path.join(here, "..", "..", "..", "templates", "dashboard.html"),
40
+ path.join(here, "..", "..", "templates", "dashboard.html"),
41
+ path.join(process.cwd(), "templates", "dashboard.html"),
42
+ ];
43
+ const htmlPath = candidates.find((candidate) => existsSync(candidate));
44
+ if (htmlPath) return readFileSync(htmlPath, "utf8");
32
45
  try {
33
46
  return readFileSync(path.join(process.cwd(), "templates", "dashboard.html"), "utf8");
34
47
  } catch {
@@ -61,7 +74,7 @@ export function createDashboardServer({ source, port = 4317, maxBuffer = 200 })
61
74
  for (const res of clients) res.write(frame);
62
75
  });
63
76
 
64
- const server = http.createServer((req, res) => {
77
+ const server = http.createServer(async (req, res) => {
65
78
  const url = (req.url || "/").split("?")[0];
66
79
 
67
80
  if (url === "/") {
@@ -71,12 +84,58 @@ export function createDashboardServer({ source, port = 4317, maxBuffer = 200 })
71
84
  }
72
85
 
73
86
  if (url === "/state") {
87
+ if (typeof source.refresh === "function") await source.refresh().catch(() => {});
74
88
  const status = typeof source.getStatus === "function" ? source.getStatus() : {};
75
89
  res.writeHead(200, { "Content-Type": "application/json" });
76
90
  res.end(JSON.stringify(status));
77
91
  return;
78
92
  }
79
93
 
94
+ if (url === "/command" && req.method === "POST") {
95
+ const WHITELIST = {
96
+ flatten: "flatten",
97
+ stop: "stop",
98
+ closePosition: "closePosition",
99
+ cancelOrder: "cancelOrder",
100
+ };
101
+ let body = "";
102
+ req.on("data", (c) => (body += c));
103
+ req.on("end", async () => {
104
+ let cmd;
105
+ try {
106
+ cmd = JSON.parse(body || "{}");
107
+ } catch {
108
+ cmd = {};
109
+ }
110
+ const method = WHITELIST[cmd.type];
111
+ if (!method || typeof source[method] !== "function") {
112
+ res.writeHead(400, { "Content-Type": "application/json" });
113
+ res.end(JSON.stringify({ ok: false, error: `unsupported command "${cmd.type}"` }));
114
+ return;
115
+ }
116
+ try {
117
+ const arg =
118
+ cmd.type === "closePosition"
119
+ ? cmd.symbol
120
+ : cmd.type === "cancelOrder"
121
+ ? cmd.orderId
122
+ : undefined;
123
+ await source[method](arg);
124
+ res.writeHead(200, { "Content-Type": "application/json" });
125
+ res.end(JSON.stringify({ ok: true }));
126
+ } catch (error) {
127
+ res.writeHead(500, { "Content-Type": "application/json" });
128
+ res.end(
129
+ JSON.stringify({
130
+ ok: false,
131
+ error: error instanceof Error ? error.message : String(error),
132
+ })
133
+ );
134
+ }
135
+ });
136
+ return;
137
+ }
138
+
80
139
  if (url === "/events") {
81
140
  res.writeHead(200, {
82
141
  "Content-Type": "text/event-stream",
@@ -424,6 +424,11 @@ export class PaperEngine extends BrokerAdapter {
424
424
 
425
425
  const orders = [...this.openOrders.values()].filter((order) => order.symbol === symbol);
426
426
  for (const order of orders) {
427
+ // Skip orders already consumed this pass (e.g. an OCO sibling about to be
428
+ // canceled, or any order removed by a prior fill). _fillOrder deletes from
429
+ // openOrders before emitting, so this guard prevents bracket double-fills
430
+ // when one bar straddles both stop and target.
431
+ if (!this.openOrders.has(order.orderId)) continue;
427
432
  if (order.type === "limit") {
428
433
  if (this._touchesLimit(order, normalizedBar)) {
429
434
  this._fillOrder(order, order.limitPrice, "limit", normalizedBar.time);
package/src/live/index.js CHANGED
@@ -26,3 +26,5 @@ export { LiveEngine, createLiveEngine } from "./engine/liveEngine.js";
26
26
 
27
27
  export { LiveOrchestrator, createLiveOrchestrator } from "./orchestrator.js";
28
28
  export { createDashboardServer } from "./dashboard/server.js";
29
+
30
+ export { TradingSession, SessionManager, createSessionManager } from "./session.js";
@@ -0,0 +1,402 @@
1
+ import { EventBus } from "./events.js";
2
+ import { RiskManager } from "./engine/riskManager.js";
3
+ import { calculatePositionSize } from "../utils/positionSizing.js";
4
+ import { roundStep } from "../engine/execution.js";
5
+ import { PaperEngine } from "./engine/paperEngine.js";
6
+
7
+ function oppositeSide(side) {
8
+ return side === "long" || side === "buy" ? "sell" : "buy";
9
+ }
10
+
11
+ function toBrokerSide(side) {
12
+ return side === "long" || side === "buy" ? "buy" : "sell";
13
+ }
14
+
15
+ export class TradingSession {
16
+ constructor({
17
+ id,
18
+ symbol,
19
+ interval = "1m",
20
+ broker,
21
+ mode = "paper",
22
+ equity = 10_000,
23
+ riskPct = 1,
24
+ maxDailyLossPct = 0,
25
+ maxPositionPct = 1,
26
+ qtyStep = 0.001,
27
+ minQty = 0.001,
28
+ maxLeverage = 2,
29
+ confirmLive = false,
30
+ eventBus,
31
+ } = {}) {
32
+ if (mode === "live" && (!TradingSession.liveAllowed() || !confirmLive)) {
33
+ throw new Error(
34
+ "live trading is gated: set TRADELAB_ALLOW_LIVE=true and pass confirmLive:true with a credentialed broker"
35
+ );
36
+ }
37
+ if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
38
+ if (!symbol) throw new Error("TradingSession requires a symbol");
39
+
40
+ this.id = id || `${symbol}-${interval}`;
41
+ this.symbol = symbol;
42
+ this.interval = interval;
43
+ this.broker = broker;
44
+ this.mode = mode;
45
+ this.equity = equity;
46
+ this._startEquity = equity;
47
+ this.riskPct = riskPct;
48
+ this.maxPositionPct = maxPositionPct;
49
+ this.qtyStep = qtyStep;
50
+ this.minQty = minQty;
51
+ this.maxLeverage = maxLeverage;
52
+ this.eventBus = eventBus || new EventBus();
53
+ this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
54
+ this.lastPrice = null;
55
+ this.running = false;
56
+ this.events = [];
57
+ this.brackets = new Map(); // symbol -> { stopId, targetId }
58
+ this._pendingBracket = null;
59
+ this._cachedPositions = [];
60
+ this._cachedOpenOrders = [];
61
+ this.candleBuffer = [];
62
+ this._strategy = null;
63
+
64
+ this._wireBrokerEvents();
65
+ }
66
+
67
+ static liveAllowed() {
68
+ return process.env.TRADELAB_ALLOW_LIVE === "true";
69
+ }
70
+
71
+ _record(event, payload) {
72
+ const msg = { event, payload, t: Date.now() };
73
+ this.events.push(msg);
74
+ if (this.events.length > 500) this.events.shift();
75
+ this.eventBus.emitEvent(event, { sessionId: this.id, symbol: this.symbol, ...payload });
76
+ }
77
+
78
+ _wireBrokerEvents() {
79
+ // Forward broker fills/cancels onto the session bus, and run OCO bracket logic.
80
+ this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
81
+ this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
82
+ this.broker.on?.("order:canceled", (order) => this._record("order:canceled", order));
83
+ this.broker.on?.("equity:update", (acct) => this._record("equity:update", acct));
84
+ }
85
+
86
+ // Sync event handler — fire-and-forget async OCO work via a stored promise
87
+ _onBrokerFillSync(order) {
88
+ this._record("order:filled", order);
89
+
90
+ // Resting entry order (e.g. a limit) just filled — attach its staged bracket.
91
+ if (this._pendingBracket && String(order.clientOrderId || "").includes("-entry-")) {
92
+ const staged = this._pendingBracket;
93
+ this._pendingBracket = null;
94
+ // simulateBar may still be iterating orders, so schedule attach without awaiting.
95
+ this._pendingCancelPromise = Promise.resolve(
96
+ this._attachBracket({ ...staged, receipt: order })
97
+ );
98
+ return;
99
+ }
100
+
101
+ // Track bracket leg fills for OCO
102
+ const bracket = this.brackets.get(this.symbol);
103
+ if (bracket && (order.orderId === bracket.stopId || order.orderId === bracket.targetId)) {
104
+ const siblingId = order.orderId === bracket.stopId ? bracket.targetId : bracket.stopId;
105
+ // Schedule the cancel — simulateBar is still iterating orders, so we must not await here.
106
+ // We keep a pending cancel promise that refresh() awaits.
107
+ this._pendingCancelPromise = (async () => {
108
+ if (siblingId) await this.broker.cancelOrder(siblingId).catch(() => {});
109
+ this.brackets.delete(this.symbol);
110
+ this._record("position:closed", { reason: order.orderId === bracket.stopId ? "SL" : "TP" });
111
+ })();
112
+ }
113
+ }
114
+
115
+ async start() {
116
+ if (!this.broker.isConnected?.()) await this.broker.connect?.({});
117
+ const acct = await this.broker.getAccount?.().catch(() => null);
118
+ if (Number.isFinite(acct?.equity)) {
119
+ this.equity = acct.equity;
120
+ this._startEquity = acct.equity;
121
+ }
122
+ this.riskManager.initialize(this.equity, Date.now());
123
+ this.running = true;
124
+ this._record("connected", { mode: this.mode });
125
+ }
126
+
127
+ async stop({ flatten = false } = {}) {
128
+ if (flatten) await this.flatten();
129
+ this.running = false;
130
+ this._record("shutdown", {});
131
+ }
132
+
133
+ async pushBar(b) {
134
+ this.lastPrice = b.close;
135
+ if (typeof this.broker.simulateBar === "function") {
136
+ await this.broker.simulateBar(this.symbol, this.interval, b);
137
+ }
138
+ // Wait for any pending OCO cancel triggered by simulateBar fills
139
+ if (this._pendingCancelPromise) {
140
+ await this._pendingCancelPromise;
141
+ this._pendingCancelPromise = null;
142
+ }
143
+ this.candleBuffer.push(b);
144
+ if (this.candleBuffer.length > 200) this.candleBuffer.shift();
145
+ this._record("bar", { close: b.close, time: b.time });
146
+ await this._syncEquityAndRisk();
147
+ await this.refresh();
148
+ }
149
+
150
+ _riskHalted() {
151
+ const state = this.riskManager.getState?.() || {};
152
+ return Boolean(state.halted);
153
+ }
154
+
155
+ async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice } = {}) {
156
+ if (!this.running) throw new Error("session not started");
157
+ if (this._riskHalted()) throw new Error("session is risk-halted for the day");
158
+ const entryRef = type === "limit" ? limitPrice : this.lastPrice;
159
+ if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
160
+
161
+ let size = qty;
162
+ if (!Number.isFinite(size)) {
163
+ const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
164
+ if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
165
+ size = calculatePositionSize({
166
+ equity: this.equity,
167
+ entry: entryRef,
168
+ stop,
169
+ riskFraction: fraction,
170
+ qtyStep: this.qtyStep,
171
+ minQty: this.minQty,
172
+ maxLeverage: this.maxLeverage,
173
+ });
174
+ }
175
+ size = roundStep(size, this.qtyStep);
176
+ if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
177
+
178
+ const receipt = await this.broker.submitOrder({
179
+ symbol: this.symbol,
180
+ side: toBrokerSide(side),
181
+ type,
182
+ qty: size,
183
+ limitPrice: type === "limit" ? limitPrice : undefined,
184
+ clientOrderId: `${this.id}-entry-${Date.now()}`,
185
+ });
186
+
187
+ // Stage bracket if needed — market orders fill synchronously in PaperEngine
188
+ if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
189
+ if (receipt.status === "filled") {
190
+ await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
191
+ } else {
192
+ this._pendingBracket = { side, size, stop, target, rr, entryRef };
193
+ }
194
+ }
195
+
196
+ await this.refresh();
197
+ return receipt;
198
+ }
199
+
200
+ async _attachBracket({ side, size, stop, target, rr, entryRef, receipt }) {
201
+ const entryFill = receipt?.avgFillPrice ?? entryRef;
202
+ const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
203
+ const targetPrice = Number.isFinite(target)
204
+ ? target
205
+ : Number.isFinite(rr) && risk
206
+ ? side === "long" || side === "buy"
207
+ ? entryFill + rr * risk
208
+ : entryFill - rr * risk
209
+ : null;
210
+ const exitSide = oppositeSide(side);
211
+ const bracket = {};
212
+
213
+ if (Number.isFinite(stop)) {
214
+ const stopOrder = await this.broker.submitOrder({
215
+ symbol: this.symbol,
216
+ side: exitSide,
217
+ type: "stop",
218
+ qty: size,
219
+ stopPrice: stop,
220
+ clientOrderId: `${this.id}-stop-${Date.now()}`,
221
+ });
222
+ bracket.stopId = stopOrder.orderId;
223
+ }
224
+ if (Number.isFinite(targetPrice)) {
225
+ const tgtOrder = await this.broker.submitOrder({
226
+ symbol: this.symbol,
227
+ side: exitSide,
228
+ type: "limit",
229
+ qty: size,
230
+ limitPrice: targetPrice,
231
+ clientOrderId: `${this.id}-target-${Date.now()}`,
232
+ });
233
+ bracket.targetId = tgtOrder.orderId;
234
+ }
235
+ this.brackets.set(this.symbol, bracket);
236
+ }
237
+
238
+ async _syncEquityAndRisk() {
239
+ const acct = await this.broker.getAccount?.().catch(() => null);
240
+ if (!Number.isFinite(acct?.equity)) return;
241
+ const prevEquity = this.equity;
242
+ this.equity = acct.equity;
243
+ const pnlDelta = this.equity - prevEquity;
244
+ // Record the trade pnl change so RiskManager can check daily loss
245
+ if (pnlDelta !== 0) {
246
+ this.riskManager.recordTrade({ pnl: pnlDelta, timeMs: Date.now(), equity: this.equity });
247
+ } else {
248
+ this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
249
+ }
250
+ }
251
+
252
+ async closePosition(symbol = this.symbol) {
253
+ const positions = await this.broker.getPositions();
254
+ const pos = positions.find((p) => p.symbol === symbol);
255
+ if (!pos) return null;
256
+
257
+ // cancel any resting bracket first
258
+ const bracket = this.brackets.get(symbol);
259
+ if (bracket) {
260
+ for (const id of [bracket.stopId, bracket.targetId]) {
261
+ if (id) await this.broker.cancelOrder(id).catch(() => {});
262
+ }
263
+ this.brackets.delete(symbol);
264
+ }
265
+
266
+ const receipt = await this.broker.submitOrder({
267
+ symbol,
268
+ side: oppositeSide(pos.side),
269
+ type: "market",
270
+ qty: pos.qty,
271
+ clientOrderId: `${this.id}-close-${Date.now()}`,
272
+ });
273
+ await this._syncEquityAndRisk();
274
+ await this.refresh();
275
+ return receipt;
276
+ }
277
+
278
+ async flatten() {
279
+ const positions = await this.broker.getPositions();
280
+ for (const p of positions) await this.closePosition(p.symbol);
281
+ const open = (await this.broker.getOpenOrders?.().catch(() => [])) ?? [];
282
+ for (const o of open) await this.broker.cancelOrder(o.orderId).catch(() => {});
283
+ await this.refresh();
284
+ }
285
+
286
+ async cancelOrder(orderId) {
287
+ await this.broker.cancelOrder(orderId);
288
+ await this.refresh();
289
+ }
290
+
291
+ async getAccount() {
292
+ return this.broker.getAccount();
293
+ }
294
+
295
+ async getPositions() {
296
+ return this.broker.getPositions();
297
+ }
298
+
299
+ recentEvents(limit = 50) {
300
+ return this.events.slice(-limit);
301
+ }
302
+
303
+ getStatus() {
304
+ const risk = this.riskManager.getState?.() || {};
305
+ return {
306
+ id: this.id,
307
+ symbol: this.symbol,
308
+ interval: this.interval,
309
+ mode: this.mode,
310
+ running: this.running,
311
+ equity: this.equity,
312
+ dayPnl: risk.dayPnl ?? 0,
313
+ lastPrice: this.lastPrice,
314
+ positions: this._cachedPositions ?? [],
315
+ openOrders: this._cachedOpenOrders ?? [],
316
+ risk: { halted: Boolean(risk.halted), ...risk },
317
+ };
318
+ }
319
+
320
+ /** Refresh sync caches used by getStatus() */
321
+ async refresh() {
322
+ // Wait for any pending OCO cancel before refreshing state
323
+ if (this._pendingCancelPromise) {
324
+ await this._pendingCancelPromise;
325
+ this._pendingCancelPromise = null;
326
+ }
327
+ this._cachedPositions = await this.broker.getPositions().catch(() => []);
328
+ this._cachedOpenOrders = (await this.broker.getOpenOrders?.().catch(() => [])) ?? [];
329
+ const acct = await this.broker.getAccount?.().catch(() => null);
330
+ if (Number.isFinite(acct?.equity)) this.equity = acct.equity;
331
+ return this.getStatus();
332
+ }
333
+ }
334
+
335
+ export class SessionManager {
336
+ constructor({ brokerFactory } = {}) {
337
+ this.sessions = new Map();
338
+ this.brokerFactory = brokerFactory;
339
+ }
340
+
341
+ async create({
342
+ id,
343
+ mode = "paper",
344
+ symbol,
345
+ interval = "1m",
346
+ equity = 10_000,
347
+ confirmLive = false,
348
+ broker,
349
+ ...rest
350
+ } = {}) {
351
+ if (this.sessions.has(id)) throw new Error(`session "${id}" already exists`);
352
+ let resolvedBroker = broker;
353
+ if (mode === "live") {
354
+ if (!TradingSession.liveAllowed() || !confirmLive) {
355
+ throw new Error("live mode requires TRADELAB_ALLOW_LIVE=true and confirmLive:true");
356
+ }
357
+ if (!resolvedBroker && this.brokerFactory) {
358
+ resolvedBroker = this.brokerFactory({ symbol, ...rest });
359
+ }
360
+ if (!resolvedBroker) throw new Error("live mode requires a credentialed broker");
361
+ }
362
+ if (!resolvedBroker) resolvedBroker = new PaperEngine({ equity });
363
+ const session = new TradingSession({
364
+ id,
365
+ symbol,
366
+ interval,
367
+ broker: resolvedBroker,
368
+ mode,
369
+ equity,
370
+ confirmLive,
371
+ ...rest,
372
+ });
373
+ await session.start();
374
+ this.sessions.set(session.id, session);
375
+ return session;
376
+ }
377
+
378
+ get(id) {
379
+ return this.sessions.get(id) ?? null;
380
+ }
381
+
382
+ list() {
383
+ return [...this.sessions.values()];
384
+ }
385
+
386
+ async remove(id, { flatten = true } = {}) {
387
+ const s = this.sessions.get(id);
388
+ if (!s) return;
389
+ await s.stop({ flatten });
390
+ this.sessions.delete(id);
391
+ }
392
+
393
+ async haltAll() {
394
+ for (const s of this.sessions.values()) await s.stop({ flatten: true });
395
+ // Remove stopped sessions so list() does not retain them and re-runs don't collide.
396
+ this.sessions.clear();
397
+ }
398
+ }
399
+
400
+ export function createSessionManager(opts) {
401
+ return new SessionManager(opts);
402
+ }