/**
 * # session/controller — the transport-neutral incremental Session controller (kestrel-djm.4)
 *
 * The ONE programmatic, in-process interaction controller over {@link SessionCore}: it replaces the
 * file-polling author handshake ({@link import("./day.ts").runDaySession} / `fileHandshakeAgent`) with a
 * pure pull API — no filesystem, no directory, no transport — that a caller drives verb by verb:
 *
 *     start → advance → revise → resume → finalize          (+ submit — the explicit-binding wire path)
 *
 * It is the L1 (pure core: derive/admit/seal) + L2 (ergonomic surface) of djm.2's protocol v0.2 stack; L0
 * (the {@link SessionMessage} union, the {@link TurnEntry} pentad, {@link reconcileTurn}) already landed in
 * `src/protocol/session.ts`. Each response BINDS the transcript pentad `(sessionId, ordinal, parentHash,
 * frameRoot, authoredSha256)`, and every admit/refuse routes the idempotency + fail-closed ladder through
 * the SAME {@link reconcileTurn} primitive — never a bespoke check.
 *
 * ## NO-REIMPLEMENT — one truth (the non-negotiable)
 * The controller does not fork a second Session progression. It WRAPS {@link runSimulateSession} exactly
 * once via a **channel/coroutine {@link Agent}** whose `open`/`decide` suspend, hand the frozen Frame out to
 * the caller, and resume with the caller's answer. The driver's own WakeFrontier (`popNext` / `armStaleness`
 * / MIN_WAKE_SPACING / the staleness backstop) decides Wake eligibility, and the SAME `core.settle()` /
 * `core.blotter()` finalizes. So a controller-driven agent-day is BYTE-IDENTICAL to the one-shot/file path
 * over the same authored transcript (no golden churn): the coroutine emits the identical {@link AgentTurn}s
 * that a scripted `Agent` would, at the identical wake ts, onto the identical graded Bus.
 *
 * ## DETERMINISM
 * The genesis seals the pure-data spec, so `SessionId = sha256(genesis)` is a host-independent function of
 * `(bus, config, wakes, fillModel, rUsd, …)` — a different config axis ⇒ a different Session. Nothing on the
 * record path reads a wall clock or an unseeded RNG (the coroutine handoff is above the determinism line;
 * the returned turn is the only value that crosses). Identical Bus + identical authored transcript ⇒
 * byte-identical Blotter + graded-bus sha256, and `resume` IS re-derivation (nothing to restore).
 *
 * ## FAIL CLOSED — typed refusals, state untouched (djm.2 reuse)
 * An ineligible advance (`sealed` after finalize, `not-pending` at settle), a cursor mismatch
 * (`broken-chain`), or a changed-bytes-same-slot revision (`turn-conflict`) each REFUSE with a typed
 * {@link SessionDiagnostic}; a byte-identical re-submit is idempotent (content-addressing IS idempotency).
 * An explicit STAND_DOWN records a gradable `stand-down` body — FOREVER distinct from a host `failure`.
 *
 * ## 7dv.4 soft-sequencing note (bd note deferred to the orchestrator — hard rules forbid `.beads` writes)
 * The PUBLIC surface here is PROTOCOL-DEFINED (start/advance/revise/resume/finalize + submit, over the
 * SessionMessage/TurnEntry shapes of protocol v0.2), NOT today's internal driver shape. The INTERNAL wiring
 * to {@link runSimulateSession}/{@link SessionCore} is an implementation detail the later 7dv.4
 * session-unbundle may re-home WITHOUT changing this interface — the tests assert only the observable
 * contract (byte-identity, pentad binding, typed refusals), never a driver internal.
 */

import { readBus, type BusEvent } from "../bus/index.ts";
import type { Blotter } from "../blotter/index.ts";
import { sha256 } from "../crypto/sha256.ts";
import { jsonHash } from "../canonical/json.ts";
import type { BriefingInput } from "../frame/index.ts";
import type { EventCursor } from "../protocol/index.ts";
import {
  OPEN_ORDINAL,
  reconcileTurn,
  type SessionId,
  type TurnBody,
  type TurnEntry,
} from "../protocol/session.ts";
import type { ActingFrame, Agent, AgentConfig, AgentTurn, CapturedTurns } from "./agent.ts";
import { busSha256Of } from "./sim.ts";
import { runSimulateSession, type RunSimulateOptions, type SimulateResult } from "./simulate.ts";

// The transport-neutral protocol shapes the incremental controller answers in live in the LIGHT
// ./controller-types.ts leaf (built-in-free), so the djm.5 SDK's light modules can type-import them without
// dragging this heavy module's engine graph into the light build. Imported here for internal use and
// RE-EXPORTED verbatim — every existing `from "./controller.ts"` importer is unchanged (kestrel-djm.5).
import type {
  AuthoredResponse,
  BoundResponse,
  Delivery,
  SessionFrame,
  SessionResponse,
  SessionTranscript,
  TurnDisposition,
  TurnReceipt,
} from "./controller-types.ts";
export type {
  AuthoredResponse,
  BoundResponse,
  Delivery,
  SessionFrame,
  SessionResponse,
  SessionTranscript,
  TurnDisposition,
  TurnReceipt,
};

// ─────────────────────────────────────────────────────────────────────────────
// Public surface — the protocol-defined controller API (the frozen interface 7dv.4 must not disturb)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The pure-data knobs a Session runs on — exactly what {@link runSimulateSession} consumes minus the push
 * `agent` (the CALLER is the author now, answering incrementally) and minus any handshake directory (this is
 * in-process), PLUS the pinned {@link AgentConfig} comparison axis. Spec-as-data: the genesis seals THIS
 * object, so {@link SessionController.sessionId} is its deterministic content hash.
 */
export interface SessionSpec extends Omit<RunSimulateOptions, "agent"> {
  /** The comparison axis pinned to this Session (folded into the genesis ⇒ a different config = a different
   * Session). A NO-LLM Backtest carries {@link import("./agent.ts").BACKTEST_CONFIG}. */
  readonly config: AgentConfig;
}

// SessionFrame / Delivery / AuthoredResponse / BoundResponse / TurnDisposition / TurnReceipt /
// SessionResponse / SessionTranscript are declared in the LIGHT ./controller-types.ts leaf and re-exported
// from this module (see the import block above) — moved there so the djm.5 SDK's light build entry can
// type-import them off a built-in-free graph, with no change to this module's public surface.

/**
 * The sealed Session (`session/finalize`): the graded {@link Blotter} + the {@link CapturedTurns} the run
 * consumed + the run's {@link AgentConfig}, plus the Session id and `tipHash` — the receipt root a
 * CertifiedGrade can pin (the tail of the pentad chain).
 */
export interface SessionFinalized {
  readonly blotter: Blotter;
  readonly captured: CapturedTurns;
  readonly config: AgentConfig;
  readonly sessionId: SessionId;
  readonly tipHash: string;
}

/**
 * The transport-neutral incremental Session controller — the ONE programmatic surface over
 * {@link SessionCore}. Mint one with {@link openSession} (a live author) or {@link resumeSession} (re-derive
 * from a committed transcript). Every method is a pure driver OVER the existing progression, never a fork.
 */
export interface SessionController {
  /** The deterministic Session identity (= the genesis hash of the sealed spec). Available immediately. */
  readonly sessionId: SessionId;
  /** START: mint the run and deliver the date-blind OPEN Frame (ordinal {@link OPEN_ORDINAL}). */
  start(): Promise<Delivery>;
  /** AUTHORED-OR-STAND-DOWN: commit the pending slot, then deliver the next eligible Wake (or `null` at
   * settle). Fails closed if nothing is pending (`not-pending`) or the chain is sealed (`sealed`). */
  advance(response: AuthoredResponse): Promise<SessionResponse>;
  /** REVISION: author a superseding document at the current pending slot (disposition `revised` once armed).
   * Behaviourally identical to {@link advance} — a revision is just another turn at the delivered slot. */
  revise(response: AuthoredResponse): Promise<SessionResponse>;
  /** EXPLICIT-BINDING turn: reconcile a fully-bound {@link BoundResponse} against the recorded slot via
   * {@link reconcileTurn} — idempotent on a byte-identical re-send, typed-refusal on a conflict/mismatch. */
  submit(response: BoundResponse): Promise<SessionResponse>;
  /** RESUME: re-derive the committed transcript (pure — nothing to restore). `after` is accepted for the
   * cursor-replay contract; the full transcript is always a correct superset for re-derivation. */
  resume(after?: EventCursor): SessionTranscript;
  /** FINALIZE: drive to settle through the SAME Bus/Blotter/Grade path and seal the chain. */
  finalize(): Promise<SessionFinalized>;
}

/** Mint a live controller over `spec`: the caller authors each turn incrementally. */
export function openSession(spec: SessionSpec): SessionController {
  return new IncrementalSession(spec, undefined);
}

/**
 * Re-derive a controller from a committed `transcript` (resume IS re-derivation). Construction is
 * synchronous (the {@link SessionController.sessionId} is available at once); {@link SessionController.finalize}
 * replays the transcript's authored turns through the SAME driver to reconstruct a byte-identical Blotter.
 */
export function resumeSession(spec: SessionSpec, transcript: readonly TurnEntry[]): SessionController {
  return new IncrementalSession(spec, transcript);
}

// ─────────────────────────────────────────────────────────────────────────────
// Deterministic identity + entry sealing (pure — no wall clock, no RNG)
// ─────────────────────────────────────────────────────────────────────────────

// The content address of a structured record — sha256 over the canonical JSON of a value (recursively
// sorted keys, `undefined` dropped). ONE owner in {@link ../canonical/json.ts} (kestrel-zs2f): `jsonHash`
// was `deepSort` + `sha256(JSON.stringify(...))` copy-pasted here, byte-identical to the Blotter/receipt/
// config canonicalizers, and hashes through the SAME portable {@link sha256} seam (native under bun /
// pure-JS in a Worker isolate, never `Bun` direct — the genesis stays host-independent, zs2f.1).

/** The events a spec runs on: its in-memory `events`, else the recorded bus at `busPath`. Read ONCE for the
 * genesis; {@link runSimulateSession} reads its own copy at drive time (both deterministic). */
function eventsOf(spec: SessionSpec): readonly BusEvent[] {
  if (spec.events !== undefined) return spec.events;
  if (spec.busPath !== undefined) return [...readBus(spec.busPath)];
  throw new Error("session/controller: SessionSpec needs either `events` or `busPath`");
}

/** Seal the pure-data spec into a genesis and derive `SessionId = sha256(genesis)`. Deterministic and
 * host-independent: the bus is folded as its canonical content hash, the config verbatim (so a different
 * config axis ⇒ a different Session); the non-data knobs (`fairTauYears`, a function) are excluded. */
function sessionIdOf(spec: SessionSpec): SessionId {
  const genesis = {
    bus: busSha256Of(eventsOf(spec)),
    config: spec.config,
    wakes: spec.wakes ?? [],
    fillModel: spec.fillModel,
    rUsd: spec.rUsd,
    ...(spec.authorCutoff !== undefined ? { authorCutoff: spec.authorCutoff } : {}),
    ...(spec.structural !== undefined ? { structural: spec.structural } : {}),
    ...(spec.instruments !== undefined ? { instruments: spec.instruments } : {}),
    ...(spec.makerFairParams !== undefined ? { makerFairParams: spec.makerFairParams } : {}),
  };
  return sha256(jsonHash(genesis)) as SessionId;
}

/** The idempotency address of a turn body: the exact authored bytes for `authored`, else the body's own
 * content (never empty — the {@link reconcileTurn} malformed guard rejects an empty authored address). */
function authoredSha256Of(body: TurnBody): string {
  switch (body.kind) {
    case "authored":
      return sha256(body.bytes);
    case "stand-down":
      return sha256(body.reason);
    case "failure":
      return sha256(body.failureClass);
  }
}

/** Seal a committed {@link TurnEntry}: bind the pentad `(sessionId, ordinal, parentHash, frameRoot,
 * authoredSha256)` + body, then content-address the whole entry as `entryHash` (the next turn's cursor).
 * `entryHash` is deterministic in the entry's content, so a byte-identical turn re-seals to the same address
 * (the substrate {@link reconcileTurn} idempotency rides on). */
function sealEntry(
  sessionId: SessionId,
  ordinal: number,
  parentHash: string,
  frameRoot: string,
  body: TurnBody,
): TurnEntry {
  const core = {
    kind: "turn" as const,
    sessionId,
    ordinal,
    parentHash,
    frameRoot,
    authoredSha256: authoredSha256Of(body),
    body,
  };
  return { ...core, entryHash: jsonHash(core) };
}

// ─────────────────────────────────────────────────────────────────────────────
// Response ⇄ turn/body mapping (the ergonomic L2 ↔ the driver seam ↔ the transcript record)
// ─────────────────────────────────────────────────────────────────────────────

/** Map an authored response to the driver's {@link AgentTurn} — the value that crosses the determinism
 * line. IDENTICAL to what a scripted `Agent` would return (a supersede document / an empty pass / a
 * standDown), which is precisely why a controller-driven day is byte-identical to the one-shot path. */
function responseToTurn(r: AuthoredResponse): AgentTurn {
  switch (r.kind) {
    case "authored":
      return { actions: [{ kind: "supersede", document: r.document }] };
    case "pass":
      return { actions: [] };
    case "stand-down":
      return { actions: [{ kind: "standDown", reason: r.reason }] };
  }
}

/** Map an authored response to its recorded {@link TurnBody}. A `pass` is an empty authored body (the author
 * produced no new document) — kept distinct from a `stand-down` (an explicit de-arm) so the record never
 * conflates the two. */
function responseToBody(r: AuthoredResponse): TurnBody {
  switch (r.kind) {
    case "authored":
      return { kind: "authored", bytes: r.document };
    case "pass":
      return { kind: "authored", bytes: "" };
    case "stand-down":
      return { kind: "stand-down", reason: r.reason };
  }
}

/** Re-derive the authored response from a recorded {@link TurnBody} (the resume path). An empty authored
 * body re-derives to a `pass` (faithful — an empty document was recorded for a no-op continuation), a
 * `failure` body to a best-effort `pass` (a host fault has no re-authorable content). */
function bodyToResponse(body: TurnBody): AuthoredResponse {
  switch (body.kind) {
    case "authored":
      return body.bytes === "" ? { kind: "pass" } : { kind: "authored", document: body.bytes };
    case "stand-down":
      return { kind: "stand-down", reason: body.reason };
    case "failure":
      return { kind: "pass" };
  }
}

/** The disposition a response earns given whether the standing document was already armed. */
function dispositionOf(r: AuthoredResponse, hasArmed: boolean): TurnDisposition {
  switch (r.kind) {
    case "authored":
      return hasArmed ? "revised" : "armed";
    case "pass":
      return "pass";
    case "stand-down":
      return "stood-down";
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// The coroutine channel — one value crosses the determinism line, one progression drives
// ─────────────────────────────────────────────────────────────────────────────

/** What the wrapped {@link runSimulateSession} produces at each rendezvous: a delivered Frame the caller
 * must answer (carrying its `respond` continuation), the terminal settle, or a driver fault. */
type DriverEvent =
  | { readonly type: "frame"; readonly ordinal: number; readonly frameRoot: string; readonly frame: SessionFrame; readonly respond: (turn: AgentTurn) => void }
  | { readonly type: "done"; readonly result: SimulateResult }
  | { readonly type: "error"; readonly error: unknown };

/** Controller lifecycle phase. `idle` before start; `pending` while a Frame awaits an answer; `settled`
 * once the driver reached settle (pre-finalize); `sealed` after finalize (the chain is closed). */
type Phase = "idle" | "pending" | "settled" | "sealed";

// ─────────────────────────────────────────────────────────────────────────────

class IncrementalSession implements SessionController {
  readonly sessionId: SessionId;

  private phase: Phase = "idle";
  private cursor: string; // the prior-cursor for the next delivery (genesis, then each committed entryHash)
  private hasArmed = false;
  private readonly committed: TurnReceipt[] = [];

  // The coroutine rendezvous handles.
  private resolveEvent: ((e: DriverEvent) => void) | null = null;
  private pending: Delivery | null = null;
  private pendingRespond: ((turn: AgentTurn) => void) | null = null;
  private driverDone: Promise<SimulateResult> | null = null;
  private result: SimulateResult | null = null;
  private finalized: SessionFinalized | null = null;

  constructor(
    private readonly spec: SessionSpec,
    private readonly replay: readonly TurnEntry[] | undefined,
  ) {
    this.sessionId = sessionIdOf(spec);
    this.cursor = this.sessionId; // the OPEN turn's prior cursor IS the genesis (= the SessionId)
  }

  // ── START ──────────────────────────────────────────────────────────────────

  async start(): Promise<Delivery> {
    if (this.phase !== "idle") throw new Error("session/controller: start() may only be called once, first");
    const first = this.nextEvent();
    // Kick off the ONE wrapped progression. The coroutine agent suspends at agent.open synchronously within
    // this call, resolving `first` with the OPEN Frame before runSimulateSession's promise even returns.
    this.driverDone = runSimulateSession(this.buildOpts());
    this.driverDone.then(
      (result) => this.resolveEvent?.({ type: "done", result }),
      (error) => this.resolveEvent?.({ type: "error", error }),
    );
    const ev = await first;
    if (ev.type === "error") {
      this.phase = "sealed";
      throw ev.error instanceof Error ? ev.error : new Error(String(ev.error));
    }
    if (ev.type === "done") {
      // The driver settled without delivering an OPEN Frame — structurally impossible for the sim driver
      // (it always calls agent.open first), but fail loud rather than return an undefined delivery.
      this.phase = "settled";
      this.result = ev.result;
      throw new Error("session/controller: driver settled without delivering an OPEN Frame");
    }
    return this.receiveFrame(ev);
  }

  // ── ADVANCE / REVISE — commit the pending slot, deliver the next eligible Wake ─────────────────────────

  advance(response: AuthoredResponse): Promise<SessionResponse> {
    return this.commit(response);
  }

  revise(response: AuthoredResponse): Promise<SessionResponse> {
    return this.commit(response);
  }

  private async commit(response: AuthoredResponse): Promise<SessionResponse> {
    if (this.phase === "sealed") return { ok: false, diagnostic: "sealed" };
    if (this.phase !== "pending" || this.pending === null || this.pendingRespond === null) {
      return { ok: false, diagnostic: "not-pending" };
    }
    const slot = this.pending;
    const disposition = dispositionOf(response, this.hasArmed);
    const entry = sealEntry(this.sessionId, slot.ordinal, slot.parentHash, slot.frameRoot, responseToBody(response));
    const receipt: TurnReceipt = { entry, disposition };
    this.committed.push(receipt);
    this.cursor = entry.entryHash;
    if (response.kind === "authored") this.hasArmed = true;

    // Hand the mapped turn to the suspended driver and rendezvous on its next event (the next Wake Frame, or
    // the terminal settle). Arm the next rendezvous BEFORE responding so the driver's continuation can't
    // outrun us.
    const respond = this.pendingRespond;
    this.pending = null;
    this.pendingRespond = null;
    const nextEv = this.nextEvent();
    respond(responseToTurn(response));
    const ev = await nextEv;

    let next: Delivery | null;
    if (ev.type === "error") {
      this.phase = "sealed";
      throw ev.error instanceof Error ? ev.error : new Error(String(ev.error));
    }
    if (ev.type === "done") {
      this.phase = "settled";
      this.result = ev.result;
      next = null;
    } else {
      next = this.receiveFrame(ev);
    }
    return { ok: true, receipt, next };
  }

  // ── SUBMIT — the explicit-binding, reconcile-against-the-record wire path ─────────────────────────────

  async submit(response: BoundResponse): Promise<SessionResponse> {
    if (this.phase === "sealed") return { ok: false, diagnostic: "sealed" };
    const incoming = sealEntry(response.sessionId, response.ordinal, response.parentHash, response.frameRoot, response.body);

    // An already-committed slot ⇒ reconcile via the SAME primitive: an idempotent duplicate returns the
    // settled receipt, a conflict/mismatch fails closed with the typed diagnostic (state untouched).
    const prior = this.committed.find((c) => c.entry.ordinal === response.ordinal);
    if (prior !== undefined) {
      const res = reconcileTurn(prior.entry, incoming);
      if (res.ok) return { ok: true, receipt: prior, next: this.pending };
      return { ok: false, diagnostic: res.diagnostic };
    }

    // Not yet committed: a fresh bound turn at the current pending slot must bind to the delivered Frame.
    if (this.phase === "pending" && this.pending !== null && this.pending.ordinal === response.ordinal) {
      if (response.sessionId !== this.sessionId) return { ok: false, diagnostic: "wrong-session" };
      if (response.parentHash !== this.pending.parentHash) return { ok: false, diagnostic: "broken-chain" };
      if (response.frameRoot !== this.pending.frameRoot) return { ok: false, diagnostic: "stale-frame" };
      return this.commit(bodyToResponse(response.body));
    }

    if (this.phase === "settled") return { ok: false, diagnostic: "not-pending" };
    return { ok: false, diagnostic: "broken-chain" };
  }

  // ── RESUME — re-derive the committed transcript (pure; nothing to restore) ────────────────────────────

  resume(_after?: EventCursor): SessionTranscript {
    return { sessionId: this.sessionId, entries: this.committed.map((c) => c.entry) };
  }

  // ── FINALIZE — drive to settle through the SAME path, seal the chain ──────────────────────────────────

  async finalize(): Promise<SessionFinalized> {
    if (this.finalized !== null) return this.finalized;

    if (this.phase === "idle") {
      await this.start();
      if (this.replay !== undefined) {
        // resume IS re-derivation: replay the committed transcript's authored turns through the SAME driver.
        for (const entry of this.replay) {
          if (this.phaseNow() !== "pending") break;
          await this.commit(bodyToResponse(entry.body));
        }
      }
    }

    // Drive any remaining eligible Wakes to settle (a legitimate pass keeps the standing Plans managing).
    while (this.phaseNow() === "pending") {
      const r = await this.commit({ kind: "pass" });
      if (!r.ok) break;
    }

    if (this.phaseNow() !== "settled" || this.result === null) {
      throw new Error(`session/controller: cannot finalize (phase ${this.phaseNow()})`);
    }
    this.phase = "sealed";
    const finalized: SessionFinalized = {
      blotter: this.result.blotter,
      captured: this.result.captured,
      config: this.result.config,
      sessionId: this.sessionId,
      tipHash: this.cursor, // the tail of the pentad chain — the receipt root a CertifiedGrade can pin
    };
    this.finalized = finalized;
    return finalized;
  }

  // ── Internals ────────────────────────────────────────────────────────────────

  /** Read the current phase at its declared type — defeats TS's over-eager literal narrowing across the
   * async `start()`/`commit()` calls, which mutate `this.phase` invisibly to the caller's flow analysis. */
  private phaseNow(): Phase {
    return this.phase;
  }

  /** Arm the next rendezvous promise, storing its resolver for the coroutine agent (or the driver-done
   * handler) to fire. */
  private nextEvent(): Promise<DriverEvent> {
    return new Promise<DriverEvent>((resolve) => {
      this.resolveEvent = resolve;
    });
  }

  /** Turn a delivered Frame event into the pending {@link Delivery}, binding its prior cursor. */
  private receiveFrame(ev: Extract<DriverEvent, { type: "frame" }>): Delivery {
    this.pendingRespond = ev.respond;
    const delivery: Delivery = {
      sessionId: this.sessionId,
      ordinal: ev.ordinal,
      parentHash: this.cursor, // genesis for OPEN, then the just-committed entryHash for each Wake
      frameRoot: ev.frameRoot,
      frame: ev.frame,
    };
    this.pending = delivery;
    this.phase = "pending";
    return delivery;
  }

  /** The channel/coroutine {@link Agent}: `open`/`decide` suspend, hand the frozen Frame out through the
   * rendezvous, and resume with the caller's authored turn. The delivered Frame is content-addressed into
   * `frameRoot` here (binding the pentad's Frame leg); rendering identity stays on the AgentConfig — content
   * addressing stops at the determinism line. */
  private coroutineAgent(): Agent {
    const deliver = (ordinal: number, frame: SessionFrame): Promise<AgentTurn> =>
      new Promise<AgentTurn>((respond) => {
        this.resolveEvent?.({ type: "frame", ordinal, frameRoot: jsonHash(frame), frame, respond });
      });
    return {
      config: this.spec.config,
      open: (briefing: BriefingInput) => deliver(OPEN_ORDINAL, briefing),
      decide: (frame: ActingFrame) => deliver(frame.wakeOrdinal, frame),
    };
  }

  /** Assemble the {@link RunSimulateOptions} for the wrapped driver: the spec's pure knobs + the coroutine
   * agent. The `config` axis rides on `agent.config`, so it is stripped from the pass-through knobs. */
  private buildOpts(): RunSimulateOptions {
    const { config: _config, ...rest } = this.spec;
    return { ...rest, agent: this.coroutineAgent() };
  }
}
