UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

1,551 lines • 55.8 kB
import { matchesSubscription } from "./subscription.js";
import { MultiCursorBuffer } from "./multi-cursor-buffer.js";
import "../../stream/constants.js";
import { ensureMessageInstances } from "../../ui/messages.js";
import { ToolCallAssembler, toClientAssembledToolCall } from "./handles/tools.js";
import { StreamingMessageAssembler, toStreamingMessageHandle } from "./messages.js";
import { MediaAssembler } from "./media.js";
import { SubgraphDiscoveryHandle } from "./handles/subgraphs.js";
import { SubagentDiscoveryHandle } from "./handles/subagents.js";
import "./handles/index.js";
import { ProtocolError } from "./error.js";
import { isHeadlessToolInterrupt } from "../../headless-tools.js";
//#region src/client/stream/index.ts
const MESSAGE_LIKE_TYPES = new Set([
	"human",
	"user",
	"ai",
	"assistant",
	"tool",
	"system",
	"function",
	"remove"
]);
/**
* When the state payload has a `messages` array containing plain
* serialized messages (objects with a recognized `type` field), coerce
* them into `@langchain/core/messages` class instances so remote runs
* expose the same shape as in-process runs.
*
* Returns the input unchanged when the payload is not an object, does
* not include a `messages` key, or contains entries that are already
* class instances / not message-like.
*/
function coerceStateMessages(value) {
	if (value == null || typeof value !== "object" || Array.isArray(value)) return value;
	const state = value;
	const messages = state.messages;
	if (!Array.isArray(messages) || messages.length === 0) return value;
	if (!messages.some((msg) => {
		if (msg == null || typeof msg !== "object") return false;
		if (typeof msg.getType === "function") return false;
		const type = msg.type;
		return typeof type === "string" && MESSAGE_LIKE_TYPES.has(type);
	})) return value;
	return {
		...state,
		messages: ensureMessageInstances(messages)
	};
}
function namespaceKey(ns) {
	return ns.join("\0");
}
function maxSeq(current, next) {
	if (next == null) return current;
	if (current == null) return next;
	return Math.max(current, next);
}
const ROOT_TERMINAL_LIFECYCLE_EVENTS = new Set([
	"completed",
	"failed",
	"interrupted"
]);
/**
* Detect a root-namespace terminal lifecycle event. Used by
* `#startProjection`'s `endOnRootTerminal` guard to settle per-run
* dispatchers regardless of whether the shared-stream pause logic
* applies to their underlying subscription.
*/
function isRootTerminalLifecycle(event) {
	if (event.method !== "lifecycle") return false;
	if (event.params.namespace.length !== 0) return false;
	const data = event.params.data;
	return data?.event != null && ROOT_TERMINAL_LIFECYCLE_EVENTS.has(data.event);
}
function namespaceListsEqual(a, b) {
	if (a === b) return true;
	if (a === void 0 || b === void 0) return false;
	if (a.length !== b.length) return false;
	const aKeys = /* @__PURE__ */ new Set();
	for (const ns of a) aKeys.add(namespaceKey(ns));
	for (const ns of b) if (!aKeys.has(namespaceKey(ns))) return false;
	return true;
}
/**
* Structural equality on filters. Two filters are equal iff they
* request the same channel set, the same namespace prefix set
* (with `undefined` meaning wildcard), and the same depth
* (with `undefined` meaning unbounded).
*/
function filterEqual(a, b) {
	if (a === b) return true;
	if (a == null || b == null) return false;
	if (a.channels.length !== b.channels.length) return false;
	const aChannels = new Set(a.channels);
	for (const ch of b.channels) if (!aChannels.has(ch)) return false;
	if (!namespaceListsEqual(a.namespaces, b.namespaces)) return false;
	if ((a.depth ?? null) !== (b.depth ?? null)) return false;
	return true;
}
function isPrefix(prefix, candidate) {
	if (prefix.length > candidate.length) return false;
	for (let i = 0; i < prefix.length; i += 1) if (prefix[i] !== candidate[i]) return false;
	return true;
}
/**
* Whether the `coverer` filter delivers every event a subscription
* opened with `target` could want.
*
* Rules:
*  - Channels: target.channels must be a subset of coverer.channels.
*  - Namespaces:
*    - coverer wildcard (`undefined`) → coverer covers all prefixes.
*    - coverer explicit + target wildcard → not covered.
*    - both explicit → every target prefix must have some coverer
*      prefix that is its ancestor (coverer's prefix delivers events
*      for all descendants, modulo depth).
*  - Depth:
*    - coverer unbounded (`undefined`) → depth is covered.
*    - otherwise, for each target prefix `tp` covered by coverer
*      prefix `cp`, the maximum event depth target wants
*      (`tp.length + (target.depth ?? ∞) - cp.length`) must be
*      `<= coverer.depth`. For a wildcard target with bounded depth,
*      target's max absolute depth is `target.depth` (prefix is `[]`).
*/
function filterCovers(coverer, target) {
	const covererChannels = new Set(coverer.channels);
	for (const ch of target.channels) if (!covererChannels.has(ch)) return false;
	const covererDepth = coverer.depth;
	const targetDepth = target.depth;
	if (coverer.namespaces == null) {
		if (covererDepth == null) return true;
		if (targetDepth == null) return false;
		return targetDepth <= covererDepth;
	}
	if (target.namespaces == null) return false;
	for (const tp of target.namespaces) if (!coverer.namespaces.some((cp) => {
		if (!isPrefix(cp, tp)) return false;
		if (covererDepth == null) return true;
		if (targetDepth == null) return false;
		return tp.length - cp.length + targetDepth <= covererDepth;
	})) return false;
	return true;
}
function normalizeSubscribeParams(paramsOrChannels, options = {}) {
	if (typeof paramsOrChannels === "object" && !Array.isArray(paramsOrChannels) && "channels" in paramsOrChannels) return paramsOrChannels;
	const channels = Array.isArray(paramsOrChannels) ? [...paramsOrChannels] : [paramsOrChannels];
	return {
		...options,
		channels
	};
}
/**
* Fold the ergonomic top-level `forkFrom` checkpoint id into
* `config.configurable.checkpoint_id` and strip `forkFrom` from the
* outgoing params.
*
* `forkFrom` is purely an SDK-side convenience: callers say
* `submit(input, { forkFrom })` instead of hand-building a nested
* RunnableConfig. The agent server only ever accepts the fork target via
* `config.configurable.checkpoint_id` (the same field the legacy run
* endpoints use), so we translate here — before the `run.start` message
* hits the wire — keeping a single, legacy-compliant way to provide it.
*
* `forkFrom` takes precedence over any `checkpoint_id` the caller already
* placed in `config.configurable`, matching the prior server-side merge.
*/
function foldForkFromIntoConfig(params) {
	const { forkFrom, ...rest } = params;
	if (typeof forkFrom !== "string" || forkFrom.length === 0) return rest;
	const config = rest.config != null && typeof rest.config === "object" ? rest.config : {};
	const configurable = config.configurable != null && typeof config.configurable === "object" ? config.configurable : {};
	return {
		...rest,
		config: {
			...config,
			configurable: {
				...configurable,
				checkpoint_id: forkFrom
			}
		}
	};
}
/**
* Async iterable handle for raw event subscriptions.
*
* An optional `transform` maps each incoming event before it is queued
* or delivered to a waiting consumer. This is used by named custom
* channel subscriptions (e.g. `"custom:a2a"`) to unwrap the payload
* so callers receive the raw emitted data instead of the protocol
* event envelope.
*/
var SubscriptionHandle = class {
	subscriptionId;
	params;
	queue = [];
	waiters = [];
	closed = false;
	paused = false;
	resumeResolve;
	onUnsubscribe;
	transform;
	constructor(subscriptionId, params, onUnsubscribe, transform) {
		this.subscriptionId = subscriptionId;
		this.params = params;
		this.onUnsubscribe = onUnsubscribe;
		this.transform = transform ?? ((event) => event);
	}
	push(event) {
		if (this.closed) return;
		const value = this.transform(event);
		const waiter = this.waiters.shift();
		if (waiter) {
			waiter({
				done: false,
				value
			});
			return;
		}
		this.queue.push(value);
	}
	/**
	* Pause the subscription: resolve all waiting iterators with `done: true`
	* so `for await` loops exit, but keep the subscription alive. New events
	* arriving while paused are still buffered. Call `resume()` to allow
	* iterators to consume again.
	*/
	pause() {
		if (this.closed) return;
		this.paused = true;
		while (this.waiters.length > 0) this.waiters.shift()?.({
			done: true,
			value: void 0
		});
	}
	/**
	* Resume a paused subscription so new `for await` loops can consume
	* buffered and future events.
	*/
	resume() {
		this.paused = false;
		this.resumeResolve?.();
		this.resumeResolve = void 0;
	}
	/**
	* Returns a promise that resolves when `resume()` is called. Resolves
	* immediately if not currently paused.
	*/
	waitForResume() {
		if (!this.paused) return Promise.resolve();
		return new Promise((resolve) => {
			this.resumeResolve = resolve;
		});
	}
	get isPaused() {
		return this.paused;
	}
	close() {
		this.closed = true;
		this.paused = false;
		while (this.waiters.length > 0) this.waiters.shift()?.({
			done: true,
			value: void 0
		});
		this.resumeResolve?.();
		this.resumeResolve = void 0;
	}
	async unsubscribe() {
		if (this.closed) return;
		this.close();
		await this.onUnsubscribe(this.subscriptionId);
	}
	[Symbol.asyncIterator]() {
		return {
			next: async () => {
				if (this.queue.length > 0) return {
					done: false,
					value: this.queue.shift()
				};
				if (this.closed || this.paused) return {
					done: true,
					value: void 0
				};
				return await new Promise((resolve) => {
					this.waiters.push(resolve);
				});
			},
			return: async () => {
				this.close();
				return {
					done: true,
					value: void 0
				};
			}
		};
	}
};
/**
* High-level wrapper around a protocol connection to a specific thread.
*
* In the thread-centric protocol, threads are durable (backed by
* checkpoints) and connections are ephemeral. A `ThreadStream` is the
* client-side handle for interacting with a thread: starting runs,
* subscribing to events, consuming assembled projections (`messages`,
* `values`, `toolCalls`, etc.), and responding to interrupts.
*
* Construct via `client.threads.stream(threadId?, { assistantId? })`.
*
* @typeParam TExtensions - Optional map of `{ name: payload }` pairs
*   describing the transformer projections the bound assistant exposes
*   on `custom:<name>` channels. Narrows `thread.extensions.<name>` to
*   `ThreadExtension<payload>`. Defaults to `Record<string, unknown>`.
*/
var ThreadStream = class {
	threadId;
	ordering = {};
	run;
	agent;
	input;
	state;
	/**
	* Whether the run was interrupted (a lifecycle "interrupted" event
	* was received). Mirrors the in-process `run.interrupted`.
	*/
	interrupted = false;
	/**
	* Interrupt payloads collected during the run, if any.
	* Mirrors the in-process `run.interrupts`.
	*/
	interrupts = [];
	assistantId;
	#nextCommandId;
	#transportAdapter;
	#pending = /* @__PURE__ */ new Map();
	#subscriptions = /* @__PURE__ */ new Map();
	#seenEventIds = /* @__PURE__ */ new Set();
	/**
	* Headless tool interrupts can be auto-resumed by the React hook before
	* the shared SSE content pump has processed the root `interrupted`
	* lifecycle event. `respondInput()` clears `interrupts`, so keep a
	* short-lived marker here until that stale terminal passes through the
	* content pump and we can avoid pausing it.
	*/
	#headlessInterruptsAwaitingTerminal = /* @__PURE__ */ new Set();
	#closed = false;
	#opened = false;
	#openPromise;
	#sharedStream = null;
	#sharedStreamFilter = null;
	#rotationState = "idle";
	/** Pending `subscribe()` promises waiting for a covering rotation. */
	#pendingSubResolves = [];
	#terminalPauseTimer;
	#terminalPauseSeq;
	#lifecycleSubId = null;
	#lifecycleStartPromise;
	#runStartReady = null;
	#lifecycleWatcherHandle = null;
	#lifecycleWatcherStartPromise;
	#onEventListeners = /* @__PURE__ */ new Set();
	#onErrorListeners = /* @__PURE__ */ new Set();
	#messagesIterable;
	#valuesProjection;
	#toolCallsIterable;
	#subgraphsIterable;
	#subagentsIterable;
	#outputPromise;
	#extensionsProxy;
	#extensionsCache = /* @__PURE__ */ new Map();
	/**
	* Shared state for the single `"custom"` channel subscription that
	* backs every `thread.extensions.<name>` handle.
	*
	* One subscription is opened eagerly from {@link run.start} (mirroring
	* the {@link values} eager-start pattern) so that per-name handles
	* created before, during, or after the run can all resolve correctly.
	*
	*  - `events` retains every custom event for backfill into
	*    late-constructed handles.
	*  - `eventListeners` fan new events out to live per-name handlers.
	*  - `endListeners` fire when the dispatcher's run terminates, so each
	*    handle can resolve its `PromiseLike` side with its last-seen
	*    payload.
	*/
	#extensionsDispatcherStarted = false;
	#extensionsEnded = false;
	#extensionsEvents = [];
	#extensionsEventListeners = [];
	#extensionsEndListeners = [];
	/**
	* Shared state for the single `messages`-channel subscription that
	* backs every media handle iterable (`thread.audio`, `thread.images`,
	* `thread.video`, `thread.files`). One subscription serves all four
	* iterables; per-type buffers track the handles already emitted so
	* late attachers replay through {@link MultiCursorBuffer}.
	*/
	#mediaDispatcherStarted = false;
	#mediaAssembler;
	/** Object URLs minted by media handles, tracked for {@link close} cleanup. */
	#mediaHandles = /* @__PURE__ */ new Set();
	#audioBuffer = new MultiCursorBuffer();
	#imagesBuffer = new MultiCursorBuffer();
	#videoBuffer = new MultiCursorBuffer();
	#filesBuffer = new MultiCursorBuffer();
	#fetchOption;
	constructor(transportAdapter, options) {
		if (!options?.assistantId) throw new Error("ThreadStream requires an assistantId option.");
		this.#transportAdapter = transportAdapter;
		this.threadId = transportAdapter.threadId;
		this.assistantId = options.assistantId;
		this.#nextCommandId = options.startingCommandId ?? 1;
		this.#fetchOption = options.fetch;
		this.run = { start: async (params) => {
			this.#prepareForNextRun();
			return await this.#withRunStartGate(() => {
				this.#ensureLifecycleTracking();
				this.values;
				return this.#send("run.start", {
					...foldForkFromIntoConfig(params),
					assistant_id: this.assistantId
				});
			});
		} };
		this.agent = { getTree: async (params = {}) => await this.#send("agent.getTree", params) };
		this.input = {
			respond: async (params) => {
				this.#prepareForNextRun();
				this.#ensureLifecycleTracking();
				this.values;
				await this.#send("input.respond", params);
			},
			inject: async (params) => {
				await this.#send("input.inject", params);
			}
		};
		this.state = {
			get: async (params) => await this.#send("state.get", params),
			listCheckpoints: async (params) => await this.#send("state.listCheckpoints", params),
			fork: async (params) => await this.#send("state.fork", params)
		};
		if (this.#transportAdapter.openEventStream == null) {
			this.#transportAdapter.setOnReconnected?.(() => this.#resubscribeWebSocketSubscriptions());
			this.#consumeEvents();
		}
	}
	/**
	* Ensure the underlying transport is connected.
	*
	* For HTTP/SSE this is a no-op. For WebSocket this performs the
	* handshake. Called lazily on first command; safe to call multiple times.
	*/
	async #ensureOpen() {
		if (this.#opened) return;
		if (this.#openPromise == null) this.#openPromise = this.#transportAdapter.open().then(() => {
			this.#opened = true;
		});
		await this.#openPromise;
	}
	/**
	* Channels bundled into every lazy getter's SSE filter so that
	* interrupt tracking works without a separate lifecycle subscription.
	*/
	#lifecycleChannels() {
		return ["lifecycle", "input"];
	}
	/**
	* Lazily start a dedicated lifecycle+input subscription so that
	* `thread.interrupted` / `thread.interrupts` work even when the
	* caller never accesses a lazy getter (e.g. they only call
	* `run.start` and `subscribe({ channels: ["custom:..."] })`).
	*
	* Idempotent and fire-and-forget — invoked from `run.start` and
	* `input.respond`.
	*/
	#ensureLifecycleTracking() {
		if (this.#lifecycleStartPromise != null) return;
		this.#lifecycleStartPromise = (async () => {
			this.#lifecycleSubId = (await this.#subscribeRaw({ channels: this.#lifecycleChannels() })).subscriptionId;
		})().catch(() => void 0);
	}
	/**
	* Run `operation` (a `run.start` send) while holding the run-start
	* gate. Sets `#runStartReady` before invoking `operation` so any
	* subscription kicked off synchronously inside it (e.g. the lifecycle
	* watcher and the values projection) sees the gate when it eventually
	* reaches `#startLifecycleWatcherSse` / `#reconcileStream` /
	* `#subscribeViaCommand` and awaits it. The gate resolves the moment
	* `operation` settles, so server-side subscribes land immediately
	* after the thread is committed.
	*/
	async #withRunStartGate(operation) {
		let resolveGate;
		let rejectGate;
		const gate = new Promise((resolve, reject) => {
			resolveGate = resolve;
			rejectGate = reject;
		});
		this.#runStartReady = gate;
		gate.catch(() => void 0);
		try {
			const result = await operation();
			resolveGate();
			return result;
		} catch (err) {
			rejectGate(err);
			throw err;
		} finally {
			if (this.#runStartReady === gate) this.#runStartReady = null;
		}
	}
	/**
	* Reset interrupt state and resume all paused user subscriptions.
	* Called before `run.start()` and `input.respond()` so that
	* iterators on the same handle pick up the next run's events.
	*
	* @param respondedInterruptId - When responding to one of several
	*   pending interrupts, only that entry is removed. Clearing the
	*   full list here would drop other headless-tool interrupts that
	*   are still awaiting client execution.
	*/
	#prepareForNextRun(respondedInterruptId) {
		this.interrupted = false;
		if (respondedInterruptId != null) {
			const respondedIds = new Set(Array.isArray(respondedInterruptId) ? respondedInterruptId : [respondedInterruptId]);
			for (let index = this.interrupts.length - 1; index >= 0; index -= 1) if (respondedIds.has(this.interrupts[index].interruptId)) this.interrupts.splice(index, 1);
		} else this.interrupts.length = 0;
		if (this.#terminalPauseTimer != null) {
			clearTimeout(this.#terminalPauseTimer);
			this.#terminalPauseTimer = void 0;
		}
		this.#terminalPauseSeq = void 0;
		for (const [id, subscription] of this.#subscriptions) if (id !== this.#lifecycleSubId) subscription.resume();
	}
	/**
	* Streaming messages. Each `for await` loop gets an independent cursor
	* over the shared buffer; late consumers see all previously emitted
	* messages.  Mirrors the in-process `run.messages`.
	*/
	get messages() {
		if (this.#messagesIterable) return this.#messagesIterable;
		const buffer = new MultiCursorBuffer();
		this.#messagesIterable = buffer;
		const assembler = new StreamingMessageAssembler();
		this.#startProjection(["messages", ...this.#lifecycleChannels()], (event) => {
			if (isRootTerminalLifecycle(event)) {
				assembler.abandonActive();
				return;
			}
			if (event.method !== "messages") return;
			const msg = assembler.consume(event);
			if (msg) buffer.push(toStreamingMessageHandle(msg));
		}, () => {
			assembler.abandonActive();
			buffer.close();
		});
		return buffer;
	}
	/**
	* State values. Iterable for intermediate snapshots; also
	* `PromiseLike` — `await thread.values` resolves with the final
	* state.  Mirrors the in-process `run.values`.
	*/
	get values() {
		if (this.#valuesProjection) return this.#valuesProjection;
		const buffer = new MultiCursorBuffer();
		let lastValue;
		let resolveOutput;
		const outputPromise = new Promise((resolve) => {
			resolveOutput = resolve;
		});
		this.#outputPromise = outputPromise;
		const projection = Object.assign(buffer, { then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected) });
		this.#valuesProjection = projection;
		this.#startProjection(["values", ...this.#lifecycleChannels()], (event) => {
			if (event.method !== "values") return;
			const data = coerceStateMessages(event.params.data);
			lastValue = data;
			buffer.push(data);
		}, () => {
			resolveOutput(lastValue);
			buffer.close();
		});
		return projection;
	}
	/**
	* Tool calls with a promise-based {@link output} for script consumers.
	* Mirrors the in-process `run.toolCalls`.
	*/
	get toolCalls() {
		if (this.#toolCallsIterable) return this.#toolCallsIterable;
		const buffer = new MultiCursorBuffer();
		this.#toolCallsIterable = buffer;
		const assembler = new ToolCallAssembler();
		this.#startProjection(["tools", ...this.#lifecycleChannels()], (event) => {
			if (event.method !== "tools") return;
			const tc = assembler.consume(event);
			if (tc) buffer.push(toClientAssembledToolCall(tc));
		}, () => buffer.close());
		return buffer;
	}
	/**
	* Discovered subgraphs. Mirrors the in-process `run.subgraphs`.
	*/
	get subgraphs() {
		if (this.#subgraphsIterable) return this.#subgraphsIterable;
		const buffer = new MultiCursorBuffer();
		this.#subgraphsIterable = buffer;
		(async () => {
			const discovery = new SubgraphDiscoveryHandle(await this.#subscribeRaw({ channels: ["tools", ...this.#lifecycleChannels()] }), this, []);
			for await (const sub of discovery) buffer.push(sub);
			buffer.close();
		})();
		return buffer;
	}
	/**
	* Discovered subagents.
	*/
	get subagents() {
		if (this.#subagentsIterable) return this.#subagentsIterable;
		const buffer = new MultiCursorBuffer();
		this.#subagentsIterable = buffer;
		(async () => {
			const discovery = new SubagentDiscoveryHandle(await this.#subscribeRaw({ channels: ["tools", ...this.#lifecycleChannels()] }), this);
			for await (const sub of discovery) buffer.push(sub);
			buffer.close();
		})();
		return buffer;
	}
	/**
	* Audio media handles, one per message containing at least one
	* `AudioBlock`. Each `for await` opens an independent cursor over
	* the shared buffer; late consumers replay every previously emitted
	* audio handle.
	*
	* Yields one item per message on the first matching
	* `content-block-start` — messages with no audio blocks are skipped.
	*/
	get audio() {
		this.#ensureMediaDispatcher();
		return this.#audioBuffer;
	}
	/**
	* Image media handles, one per message containing at least one
	* `ImageBlock`. See {@link audio} for shared semantics.
	*/
	get images() {
		this.#ensureMediaDispatcher();
		return this.#imagesBuffer;
	}
	/**
	* Video media handles, one per message containing at least one
	* `VideoBlock`. See {@link audio} for shared semantics.
	*/
	get video() {
		this.#ensureMediaDispatcher();
		return this.#videoBuffer;
	}
	/**
	* File media handles, one per message containing at least one
	* `FileBlock`. See {@link audio} for shared semantics.
	*/
	get files() {
		this.#ensureMediaDispatcher();
		return this.#filesBuffer;
	}
	/**
	* Promise that resolves with the final state value when the run
	* completes.  Shares the `values` getter's SSE connection.
	* Mirrors the in-process `run.output`.
	*/
	get output() {
		this.values;
		return this.#outputPromise;
	}
	/**
	* Proxy over compile-time {@link StreamTransformer} projections
	* exposed by the bound assistant on `custom:<name>` channels.
	*
	* Each access (e.g. `thread.extensions.toolActivity`) lazily opens a
	* dedicated `custom:<name>` subscription, returns a cached
	* {@link ThreadExtension} handle that is both `AsyncIterable<T>`
	* (streaming items as they arrive) and `PromiseLike<T>` (resolves
	* with the final value when the run terminates), and reuses the same
	* handle on subsequent access.
	*
	* Mirrors the in-process `run.extensions.<name>` shape.
	*/
	get extensions() {
		if (this.#extensionsProxy) return this.#extensionsProxy;
		const cache = this.#extensionsCache;
		const createExtension = (name) => this.#createExtension(name);
		this.#extensionsProxy = new Proxy(Object.create(null), {
			get: (_target, prop) => {
				if (typeof prop !== "string") return void 0;
				const cached = cache.get(prop);
				if (cached) return cached;
				const extension = createExtension(prop);
				cache.set(prop, extension);
				return extension;
			},
			has: (_target, prop) => typeof prop === "string"
		});
		return this.#extensionsProxy;
	}
	/**
	* Lazily open one shared subscription on the `custom` channel that
	* buffers every custom event for this run and fans it out to any
	* per-name extension handles.
	*
	* Deliberately **lazy**: the dispatcher only starts on first access
	* to `thread.extensions.<name>`. Runs that never touch extensions
	* pay no subscription cost. Runs that touch extensions after events
	* have already fired rely on the server's per-session event buffer,
	* which replays matching events to new subscriptions.
	*
	* Each handle retains a PromiseLike that resolves with the
	* transformer's last-observed payload, independent of when the
	* caller grabs the handle (before, during, or after the run), as
	* long as the server still has the events buffered.
	*
	* Idempotent. Invoked only from {@link #createExtension}.
	*/
	#ensureExtensionsDispatcher() {
		if (this.#extensionsDispatcherStarted) return;
		this.#extensionsDispatcherStarted = true;
		this.#startProjection(["custom", ...this.#lifecycleChannels()], (event) => {
			if (event.method !== "custom") return;
			this.#extensionsEvents.push(event);
			for (const listener of this.#extensionsEventListeners) listener(event);
		}, () => {
			this.#extensionsEnded = true;
			const listeners = this.#extensionsEndListeners.splice(0);
			for (const listener of listeners) listener();
		}, { endOnRootTerminal: true });
	}
	/**
	* Open the single shared `messages`-channel subscription that backs
	* every media iterable (audio/images/video/files). Idempotent.
	*
	* The {@link MediaAssembler} fans out to four per-type
	* {@link MultiCursorBuffer}s; each buffer feeds its corresponding
	* lazy getter. One handle is yielded per `(messageId, blockType)` on
	* the first matching `content-block-start`, so messages without any
	* media blocks of a given type never appear on that iterable.
	*/
	#ensureMediaDispatcher() {
		if (this.#mediaDispatcherStarted) return;
		this.#mediaDispatcherStarted = true;
		const assembler = new MediaAssembler({
			fetch: this.#fetchOption,
			onAudio: (m) => {
				this.#mediaHandles.add(m);
				this.#audioBuffer.push(m);
			},
			onImage: (m) => {
				this.#mediaHandles.add(m);
				this.#imagesBuffer.push(m);
			},
			onVideo: (m) => {
				this.#mediaHandles.add(m);
				this.#videoBuffer.push(m);
			},
			onFile: (m) => {
				this.#mediaHandles.add(m);
				this.#filesBuffer.push(m);
			}
		});
		this.#mediaAssembler = assembler;
		this.#startProjection(["messages", ...this.#lifecycleChannels()], (event) => {
			if (event.method !== "messages") return;
			assembler.consume(event);
		}, () => {
			assembler.close();
			this.#audioBuffer.close();
			this.#imagesBuffer.close();
			this.#videoBuffer.close();
			this.#filesBuffer.close();
		});
	}
	/**
	* Build a single {@link ThreadExtension} handle for a named
	* `custom:<name>` projection.
	*
	* The handle reads from the shared extensions dispatcher: past events
	* matching {@link name} are backfilled on construction, future events
	* arrive via a registered listener, and the handle's `PromiseLike`
	* side resolves with its last-seen payload once the run terminates
	* (which may already have happened, in which case it resolves on the
	* next microtask).
	*/
	#createExtension(name) {
		this.#ensureExtensionsDispatcher();
		const buffer = new MultiCursorBuffer();
		let lastValue;
		let resolveFinal;
		const finalPromise = new Promise((resolve) => {
			resolveFinal = resolve;
		});
		const handleEvent = (event) => {
			const data = event.params.data;
			if (data?.name !== name) return;
			lastValue = data.payload;
			buffer.push(data.payload);
		};
		for (const event of this.#extensionsEvents) handleEvent(event);
		this.#extensionsEventListeners.push(handleEvent);
		const settle = () => {
			resolveFinal(lastValue);
			buffer.close();
		};
		if (this.#extensionsEnded) settle();
		else this.#extensionsEndListeners.push(settle);
		return Object.assign(buffer, { then: (onfulfilled, onrejected) => finalPromise.then(onfulfilled, onrejected) });
	}
	/**
	* Generic projection starter: opens a raw subscription with the given
	* channels, feeds events through the consumer, and calls onDone when
	* the stream ends.
	*
	* When `endOnRootTerminal` is set, the projection unsubscribes its
	* own handle one macrotask after observing a root-namespace terminal
	* lifecycle event. This is needed by projections that may be opened
	* AFTER a run already terminated: the shared-stream pause logic
	* skips subscriptions whose `registeredAfterSeq` is past the
	* terminal so raw `subscribe()` callers can keep draining replayed
	* descendants — but a per-run dispatcher (e.g. the extensions
	* pipeline) needs the projection to settle so its `PromiseLike`
	* surface resolves. The macrotask deferral mirrors the deferred
	* pause in `#handleIncoming`, giving trailing same-tick custom
	* events (transformer `finalize()` flushes) a chance to drain.
	*/
	async #startProjection(channels, onEvent, onDone, options = {}) {
		let endTimer;
		let rawHandle;
		try {
			rawHandle = await this.#subscribeRaw({ channels });
			const handle = rawHandle;
			for await (const event of handle) {
				onEvent(event);
				if (options.endOnRootTerminal && endTimer == null && isRootTerminalLifecycle(event)) endTimer = setTimeout(() => {
					endTimer = void 0;
					handle.unsubscribe().catch(() => void 0);
				}, 0);
			}
		} catch {} finally {
			if (endTimer != null) clearTimeout(endTimer);
			onDone();
		}
	}
	/**
	* Start a run without the v1 eager lazy-getter shims.
	*
	* `run.start` (the v1 entry point) eagerly opens a wildcard `values`
	* projection so `thread.output` / `thread.values` resolve regardless
	* of access order, and calls `#ensureLifecycleTracking` which opens
	* another wildcard `["lifecycle", "input"]` subscription. Both
	* subscriptions widen `#computeUnionFilter` to wildcard, defeating
	* the progressive-expansion rotation strategy.
	*
	* `submitRun` skips those shims — callers that manage their own
	* content subscriptions (such as `StreamController`) get the narrow
	* union filter they asked for. Lifecycle / interrupt tracking is
	* instead served by the dedicated `#startLifecycleWatcher`, which
	* opens a wildcard `["lifecycle", "input"]` stream alongside the
	* narrow content pump on both SSE and WebSocket transports.
	*/
	async submitRun(params) {
		this.#prepareForNextRun();
		return await this.#withRunStartGate(() => {
			this.#startLifecycleWatcher();
			return this.#send("run.start", {
				...foldForkFromIntoConfig(params),
				assistant_id: this.assistantId
			});
		});
	}
	/**
	* Respond to an interrupt without the v1 eager lazy-getter shims.
	* See {@link submitRun} for why this exists alongside
	* {@link input.respond}.
	*/
	async respondInput(params) {
		const respondedIds = "responses" in params ? params.responses.map((entry) => entry.interrupt_id) : params.interrupt_id;
		this.#prepareForNextRun(respondedIds);
		this.#startLifecycleWatcher();
		await this.#send("input.respond", params);
	}
	/**
	* Register a listener for every globally-unique event on the thread.
	*
	* Fires exactly once per `event_id` across both the content pump
	* (user `subscribe()` calls) and the lifecycle watcher. Events
	* without an `event_id` always fire through (dedup is best-effort).
	*
	* Returns an unsubscribe function. Primary consumer is
	* `StreamController`, which uses the listener to feed discovery
	* runners and pick up deeply-nested interrupts that the narrow
	* content pump wouldn't deliver.
	*/
	onEvent(listener) {
		this.#onEventListeners.add(listener);
		return () => {
			this.#onEventListeners.delete(listener);
		};
	}
	/**
	* Register a listener for stream-level failures: an unsolicited server
	* error frame, or the transport giving up on reconnecting. Returns an
	* unsubscribe function.
	*/
	onError(listener) {
		this.#onErrorListeners.add(listener);
		return () => {
			this.#onErrorListeners.delete(listener);
		};
	}
	/**
	* Lazily open the wildcard discovery watcher stream.
	*
	* Idempotent. Used by both transports, but through different
	* mechanisms:
	*
	*  - **SSE**: opens a dedicated event stream via
	*    {@link TransportAdapter.openEventStream}. The stream runs
	*    outside `#computeUnionFilter`, so the shared SSE stream's
	*    content pump can stay narrow (e.g. `depth: 1`) while we still
	*    capture every lifecycle/input event at any depth.
	*  - **WebSocket**: opens a wildcard watcher subscription
	*    subscription via the normal command path. The WS server
	*    delivers matching events on the shared command connection and
	*    `#handleIncoming` dispatches them through `#fireOnEvent` and
	*    the thread-level effects — same downstream semantics as the
	*    SSE watcher, just reusing the transport that's already open.
	*
	* Why this matters: consumers of {@link onEvent} (notably
	* `StreamController`'s subgraph/subagent discovery runners and
	* nested interrupt capture) depend on observing namespaced
	* lifecycle events at any depth. Without this watcher, WS clients
	* would only ever receive events matching the content pump's
	* narrow filter (depth 1 from the root), breaking inference rules
	* that require deeper descendants (e.g. the "has-descendants"
	* signal used to promote a subgraph host).
	*/
	#startLifecycleWatcher() {
		if (this.#lifecycleWatcherStartPromise != null) return;
		if (this.#transportAdapter.openEventStream != null) {
			this.#lifecycleWatcherStartPromise = this.#startLifecycleWatcherSse();
			return;
		}
		this.#lifecycleWatcherStartPromise = this.#startLifecycleWatcherWebSocket();
	}
	/**
	* Public, idempotent entry point to start the wildcard lifecycle
	* watcher.
	*
	* The watcher is normally started lazily by `submitRun` /
	* `respondInput` because for fresh (self-created) threads the SSE
	* stream would 404 if opened before the server has the thread row.
	* Callers that already know the thread exists server-side
	* (`StreamController.hydrate` of an existing thread) can use this
	* to start the watcher up front. The watcher subscribes to wildcard
	* lifecycle events across every namespace, so it sees arbitrarily-
	* nested subagent lifecycle messages that the narrow root content
	* pump (running at `depth: 1`) wouldn't reach — that's what makes
	* subagent discovery work for historical thread loads.
	*
	* Idempotent — repeat calls reuse the in-flight start promise.
	*/
	startLifecycleWatcher() {
		this.#startLifecycleWatcher();
	}
	async #startLifecycleWatcherSse() {
		if (this.#runStartReady != null) try {
			await this.#runStartReady;
		} catch {
			return;
		}
		const filter = { channels: ["lifecycle", "input"] };
		let handle;
		try {
			handle = this.#transportAdapter.openEventStream(filter);
		} catch {
			return;
		}
		try {
			await handle.ready;
		} catch {
			try {
				handle.close();
			} catch {}
			return;
		}
		if (this.#closed) {
			try {
				handle.close();
			} catch {}
			return;
		}
		this.#lifecycleWatcherHandle = handle;
		try {
			for await (const message of handle.events) {
				if (this.#closed) break;
				this.#handleLifecycleWatcherMessage(message);
			}
		} catch {}
	}
	async #startLifecycleWatcherWebSocket() {
		let handle;
		try {
			handle = await this.#subscribeRaw({ channels: ["lifecycle", "input"] });
		} catch {
			return;
		}
		if (this.#closed) {
			try {
				handle.close();
			} catch {}
			return;
		}
		try {
			for await (const _event of handle) if (this.#closed) break;
		} catch {}
	}
	/**
	* Process an event from the dedicated lifecycle watcher stream.
	*
	* Unlike `#handleIncoming`, this does NOT fan out to user
	* subscriptions — user subs with namespace wildcards already widen
	* `#computeUnionFilter` and therefore receive the event on the
	* content pump. Delivering via both streams would only add per-sub
	* dedup churn without expanding what the user can observe.
	*
	* We still run global-dedup thread-level side effects (interrupt
	* capture, `onEvent` fan-out) so deeply-nested interrupts outside
	* the content pump's narrow scope are recorded.
	*/
	#handleLifecycleWatcherMessage(message) {
		if (message.type !== "event") return;
		if (typeof message.seq === "number") this.ordering.lastSeenSeq = maxSeq(this.ordering.lastSeenSeq, message.seq);
		if (message.event_id) this.ordering.lastEventId = message.event_id;
		const eventId = message.event_id ?? void 0;
		const globallyProcessed = eventId != null && this.#seenEventIds.has(eventId);
		if (eventId != null) this.#seenEventIds.add(eventId);
		if (globallyProcessed) return;
		this.#applyThreadLevelEffects(message);
		this.#fireOnEvent(message);
	}
	#applyThreadLevelEffects(event) {
		if (event.method === "lifecycle") {
			if (event.params.data.event === "interrupted") this.interrupted = true;
		}
		if (event.method === "input.requested") {
			const data = event.params.data;
			const interruptId = data.interrupt_id ?? `interrupt_${this.interrupts.length}`;
			this.interrupts.push({
				interruptId,
				payload: data.payload,
				namespace: [...event.params.namespace]
			});
			if (isHeadlessToolInterrupt(data.payload)) this.#headlessInterruptsAwaitingTerminal.add(interruptId);
		}
	}
	#fireOnEvent(event) {
		if (this.#onEventListeners.size === 0) return;
		for (const listener of this.#onEventListeners) try {
			listener(event);
		} catch {}
	}
	#fireOnError(error) {
		for (const listener of this.#onErrorListeners) try {
			listener(error);
		} catch {}
	}
	async close() {
		if (this.#closed) return;
		this.#closed = true;
		if (this.#terminalPauseTimer != null) {
			clearTimeout(this.#terminalPauseTimer);
			this.#terminalPauseTimer = void 0;
		}
		this.#terminalPauseSeq = void 0;
		for (const pending of this.#pendingSubResolves) pending.reject(/* @__PURE__ */ new Error("ThreadStream closed"));
		this.#pendingSubResolves.length = 0;
		if (this.#sharedStream != null) {
			try {
				this.#sharedStream.close();
			} catch {}
			this.#sharedStream = null;
			this.#sharedStreamFilter = null;
		}
		if (this.#lifecycleWatcherHandle != null) {
			try {
				this.#lifecycleWatcherHandle.close();
			} catch {}
			this.#lifecycleWatcherHandle = null;
		}
		const lifecycleWatcherStartPromise = this.#lifecycleWatcherStartPromise;
		this.#lifecycleWatcherStartPromise = void 0;
		this.#onEventListeners.clear();
		this.#onErrorListeners.clear();
		for (const subscription of this.#subscriptions.values()) subscription.close();
		this.#subscriptions.clear();
		try {
			await lifecycleWatcherStartPromise;
		} catch {}
		for (const handle of this.#mediaHandles) try {
			handle.revoke();
		} catch {}
		this.#mediaHandles.clear();
		this.#mediaAssembler?.close();
		this.#audioBuffer.close();
		this.#imagesBuffer.close();
		this.#videoBuffer.close();
		this.#filesBuffer.close();
		await this.#transportAdapter.close();
	}
	async subscribe(paramsOrChannels, options = {}) {
		const isParamsObject = typeof paramsOrChannels === "object" && !Array.isArray(paramsOrChannels) && "channels" in paramsOrChannels;
		const params = normalizeSubscribeParams(paramsOrChannels, options);
		return await this.#subscribeRaw(params, { unwrapNamedCustom: !isParamsObject });
	}
	async #subscribeRaw(params, options = {}) {
		await this.#ensureOpen();
		const { unwrapNamedCustom = true } = options;
		const hasOnlyNamedCustom = params.channels.length > 0 && params.channels.every((ch) => ch.startsWith("custom:"));
		const transform = unwrapNamedCustom && hasOnlyNamedCustom ? (event) => event.params.data?.payload ?? event : void 0;
		if (this.#transportAdapter.openEventStream != null) return this.#subscribeViaSharedStream(params, transform);
		return this.#subscribeViaCommand(params, transform);
	}
	/**
	* Subscribe via the single shared SSE connection.
	*
	* The subscription is registered immediately in `#subscriptions` so
	* fan-out can reach it the moment events begin flowing. The returned
	* promise resolves after a stream rotation completes whose union
	* filter covers this subscription's channels — mirroring the per-sub
	* `await streamHandle.ready` semantics callers depended on.
	*
	* Every subscribe schedules a stream rotation, even when the current
	* stream's filter already covers `params`. Rotating opens a fresh
	* server-side session that replays the run's full history from
	* `seq=0`; without it a late-joining sub would only see events that
	* arrive after it registered, because the shared pump's dedup drops
	* events the existing sub already consumed. Per-sub dedup
	* (`seenEventIds`) protects existing subs from receiving the
	* replay as duplicates. Rapid subscribes in the same microtask are
	* coalesced by `#scheduleReconcile` into a single rotation.
	*/
	async #subscribeViaSharedStream(params, transform) {
		const subscriptionId = `sse-${this.#nextCommandId++}`;
		const handle = new SubscriptionHandle(subscriptionId, params, async (id) => {
			this.#subscriptions.delete(id);
			this.#scheduleReconcile();
		}, transform);
		const subscription = Object.assign(handle, {
			filter: params,
			registeredAfterSeq: this.ordering.lastSeenSeq,
			seenEventIds: /* @__PURE__ */ new Set()
		});
		this.#subscriptions.set(subscriptionId, subscription);
		const covered = new Promise((resolve, reject) => {
			this.#pendingSubResolves.push({
				filter: params,
				resolve,
				reject
			});
		});
		this.#scheduleReconcile();
		try {
			await covered;
		} catch (err) {
			this.#subscriptions.delete(subscriptionId);
			throw err;
		}
		return handle;
	}
	/**
	* Progressive-expansion union of every currently-registered
	* subscription's filter. The server receives the narrowest filter
	* that still covers every active sub so deeply-namespaced or
	* selectively-opened projections don't pull down the entire thread's
	* event firehose.
	*
	* Unioning rules (matching the server's matching semantics in
	* `matchesSinkFilter`):
	*  - Channels: set union.
	*  - Namespaces: if any subscription requests a wildcard
	*    (`namespaces === undefined`) the union is wildcard; otherwise
	*    the union is the deduplicated list of every explicit prefix.
	*  - Depth: if any subscription is unbounded (`depth === undefined`)
	*    the union is unbounded; otherwise the union is the maximum
	*    depth across all subscriptions (matching the per-sub "max
	*    reach below the prefix" semantics).
	*
	* Returns `null` when there are no subscriptions.
	*/
	#computeUnionFilter() {
		if (this.#subscriptions.size === 0) return null;
		const channels = /* @__PURE__ */ new Set();
		let wildcardNamespaces = false;
		const namespaceMap = /* @__PURE__ */ new Map();
		let unboundedDepth = false;
		let maxDepth = 0;
		for (const sub of this.#subscriptions.values()) {
			for (const ch of sub.filter.channels) channels.add(ch);
			if (sub.filter.namespaces == null) wildcardNamespaces = true;
			else if (!wildcardNamespaces) for (const ns of sub.filter.namespaces) namespaceMap.set(namespaceKey(ns), ns);
			if (sub.filter.depth == null) unboundedDepth = true;
			else if (!unboundedDepth && sub.filter.depth > maxDepth) maxDepth = sub.filter.depth;
		}
		const result = { channels: [...channels] };
		if (!wildcardNamespaces) result.namespaces = [...namespaceMap.values()];
		if (!unboundedDepth) result.depth = maxDepth;
		return result;
	}
	/**
	* Schedule a stream reconciliation for the next microtask.
	*
	* Coalesces multiple subscribe/unsubscribe calls in the same tick
	* into a single rotation, and serializes across ticks (no two
	* rotations ever run concurrently).
	*/
	#scheduleReconcile() {
		if (this.#closed) return;
		if (this.#rotationState !== "idle") return;
		this.#rotationState = "scheduled";
		queueMicrotask(() => {
			if (this.#closed) {
				this.#rotationState = "idle";
				return;
			}
			this.#rotationState = "idle";
			this.#reconcileStream().catch(() => {
				this.#rotationState = "idle";
			});
		});
	}
	/**
	* Reconcile the shared SSE stream to match the desired union filter.
	*
	* Rotation strategy: open the new stream first, await its `ready`,
	* then close the old one. Overlap is absorbed by `#seenEventIds`
	* dedup in `#handleIncoming`.
	*
	* Error handling:
	*   - Failure before `ready` resolves: reject all pending `subscribe`
	*     promises whose filter isn't covered by the existing stream,
	*     and keep the existing stream running for other subscriptions.
	*   - Failure mid-pump on the active stream: close the thread via
	*     {@link #failThreadWithError} so higher layers can rebind.
	*/
	async #reconcileStream() {
		if (this.#closed) return;
		if (this.#rotationState === "rotating") return;
		const desired = this.#computeUnionFilter();
		if (desired == null) return;
		if (this.#runStartReady != null) {
			try {
				await this.#runStartReady;
			} catch (err) {
				const normalized = err instanceof Error ? err : /* @__PURE__ */ new Error("run.start failed");
				this.#rejectUncoveredPending(normalized);
				return;
			}
			if (this.#closed) return;
			if (this.#rotationState === "rotating") return;
		}
		if (this.#sharedStreamFilter != null && filterEqual(desired, this.#sharedStreamFilter) && this.#pendingSubResolves.length === 0) {
			this.#resolvePending();
			return;
		}
		this.#rotationState = "rotating";
		let newHandle;
		try {
			newHandle = this.#transportAdapter.openEventStream(desired);
		} catch (err) {
			this.#rotationState = "idle";
			this.#rejectUncoveredPending(err);
			return;
		}
		try {
			await newHandle.ready;
		} catch (err) {
			this.#rotationState = "idle";
			try {
				newHandle.close();
			} catch {}
			this.#rejectUncoveredPending(err);
			return;
		}
		if (this.#closed) {
			try {
				newHandle.close();
			} catch {}
			this.#rotationState = "idle";
			return;
		}
		this.#pumpStream(newHandle);
		const oldHandle = this.#sharedStream;
		this.#sharedStream = newHandle;
		this.#sharedStreamFilter = desired;
		if (oldHandle != null) try {
			oldHandle.close();
		} catch {}
		this.#rotationState = "idle";
		this.#resolvePending();
		const next = this.#computeUnionFilter();
		if (next != null && !filterEqual(next, this.#sharedStreamFilter)) this.#scheduleReconcile();
	}
	/**
	* Pump events from a shared-stream handle into `#handleIncoming`.
	* One pump task runs per open stream; during rotation overlap two
	* pumps may be active briefly, with `#seenEventIds` deduping.
	*/
	async #pumpStream(handle) {
		try {
			for await (const message of handle.events) {
				if (this.#closed) break;
				this.#handleIncoming(message);
			}
		} catch (err) {
			if (handle === this.#sharedStream && !this.#closed) this.#failThreadWithError(err);
		}
	}
	/**
	* Resolve any pending `subscribe()` promises whose filter is now
	* covered by the active shared stream. Called after every successful
	* rotation (and after no-op reconciliations).
	*/
	#resolvePending() {
		if (this.#sharedStreamFilter == null) return;
		const current = this.#sharedStreamFilter;
		if (this.#pendingSubResolves.length === 0) return;
		const stillPending = [];
		for (const pending of this.#pendingSubResolves) if (filterCovers(current, pending.filter)) pending.resolve();
		else stillPending.push(pending);
		this.#pendingSubResolves.length = 0;
		this.#pendingSubResolves.push(...stillPending);
	}
	/**
	* Reject pending `subscribe()` promises whose filter isn't covered
	* by the existing stream (they're the ones that triggered the
	* failed rotation). Covered pending subs are resolved normally —
	* they didn't need the new stream.
	*/
	#rejectUncoveredPending(err) {
		if (this.#pendingSubResolves.length === 0) return;
		const current = this.#sharedStreamFilter;
		const stillPending = [];
		for (const pending of this.#pendingSubResolves) if (current != null && filterCovers(current, pending.filter)) pending.resolve();
		else stillPending.push(pending);
		this.#pendingSubResolves.length = 0;
		for (const pending of stillPending) pending.reject(err);
	}
	/**
	* Terminate the thread due to an unrecoverable shared-stream error.
	* Rejects pending commands, closes subscriptions, and marks the
	* thread closed so no further rotations occur.
	*/
	#failThreadWithError(err) {
		const normalized = err instanceof Error ? err : new Error(String(err));
		for (const pending of this.#pending.values()) pending.reject(normalized);
		this.#pending.clear();
		for (const pending of this.#pendingSubResolves) pending.reject(normalized);
		this.#pendingSubResolves.length = 0;
		for (const subscription of this.#subscriptions.values()) subscription.close();
		this.#fireOnError(normalized);
	}
	/**
	* Command-based subscription (WebSocket fallback). The server replays
	* matching buffered events on subscribe via the same WebSocket stream.
	*/
	async #subscribeViaCommand(params, transform) {
		const placeholderId = `pending:${this.#nextCommandId}:${Math.random().toString(36).slice(2, 10)}`;
		let resolvedId = placeholderId;
		const handle = new SubscriptionHandle(placeholderId, params, async () => {
			this.#subscriptions.delete(resolvedId);
			if (!this.#closed && resolvedId !== placeholderId) await this.#send("subscription.unsubscribe", { subscription_id: resolvedId }).catch((err) => {
				if (err instanceof ProtocolError && err.code === "no_such_subscription") return;
				throw err;
			});
		}, transform);
		const subscription = Object.assign(handle, {
			filter: params,
			registeredAfterSeq: this.ordering.lastSeenSeq,
			seenEventIds: /* @__PURE__ */ new Set()
		});
		this.#subscriptions.set(placeholderId, subscription);
		if (this.#runStartReady != null) try {
			await this.#runStartReady;
		} catch (err) {
			this.#subscriptions.delete(placeholderId);
			throw err;
		}
		let result;
		try {
			result = await this.#send("subscription.subscribe", params);
		} catch (err) {
			this.#subscriptions.delete(placeholderId);
			throw err;
		}
		this.#subscriptions.delete(placeholderId);
		resolvedId = result.subscription_id;
		handle.subscriptionId = resolvedId;
		this.#subscriptions.set(resolvedId, subscription);
		return handle;
	}
	/**
	* Re-issue `subscription.subscribe` for every active WS subscription
	* after the transport reconnects. The server replays buffered events on
	* the new socket; client-side `event_id` dedup suppresses duplicates.
	*/
	async #resubscribeWebSocketSubscriptions() {
		if (this.#transportAdapter.openEventStream != null || this.#closed) return;
		const entries = [...this.#subscriptions.entries()];
		await Promise.all(entries.map(async ([id, subscription]) => {
			if (id.startsWith("pending:")) return;
			try {
				const nextId = (await this.#send("subscription.subscribe", subscription.filter)).subscription_id;
				if (nextId === id) return;
				this.#subscriptions.delete(id);
				subscription.subscriptionId = nextId;
				this.#subscriptions.set(nextId, subscription);
				if (this.#lifecycleSubId === id) this.#lifecycleSubId = nextId;
			} catch {}
		}));
	}
	async #consumeEvents() {
		try {
			for await (const message of this.#transportAdapter.events()) this.#handleIncoming(message);
			for (const subscription of this.#subscriptions.values()) subscription.close();
		} catch (error) {
			this.#failThreadWithError(error);
		}
	}
	/**
	* Pause non-lifecycle subscriptions after a root terminal lifecycle.
	*
	* The pause is deferred one macrotask so same-run trailing events
	* emitted immediately after terminal (for example final `values`)
	* can still drain. `terminalSeq` lets replay attachers skip terminals
	* that happened before they registered, so late subscribers can keep
	* consuming the replayed history they joined for.
	*/
	#scheduleTerminalPause(terminalSeq) {
		if (this.#terminalPauseTimer != null) clearTimeout(this.#terminalPauseTimer);
		this.#terminalPauseSeq = terminalSeq ?? null;
		this.#terminalPauseTimer = setTimeout(() => {
			this.#terminalPauseTimer = void 0;
			if (this.#closed) return;
			for (const [id, subscription] of this.#subscriptions) {
				if (id === this.#lifecycleSubId) continue;
				if (terminalSeq != null && subscription.registeredAfterSeq != null && subscription.registeredAfterSeq >= terminalSeq) continue;
				subscription.pause();
			}
		}, 0);
	}
	#handleIncoming(message) {
		if (message.type === "event") {
			if (typeof message.seq === "number") this.ordering.lastSeenSeq = maxSeq(this.ordering.lastSeenSeq, message.seq);
			if (message.event_id) this.ordering.lastEventId = message.event_id;
			const eventId = message.event_id ?? void 0;
			const globallyProcessed = eventId != null && this.#seenEventIds.has(eventId);
			if (eventId != null) this.#seenEventIds.add(eventId);
			const TERMINAL_LIFECYCLE_EVENTS = new Set([
				"interrupted",
				"completed",
				"failed"
			]);
			if (!globallyProcessed) {
				this.#applyThreadLevelEffects(message);
				this.#fireOnEvent(message);
			}
			let fannedToAny = false;
			for (const subscription of this.#subscriptions.values()) {
				if (!matchesSubscription(message, subscription.filter)) continue;
				if (eventId != null) {
					if (subscription.seenEventIds.has(eventId)) continue;
					subscription.seenEventIds.add(eventId);
				}
				subscription.push(message);
				fannedToAny = true;
			}
			if (fannedToAny && this.#terminalPauseSeq !== void 0) {
				const eventSeq = typeof message.seq === "number" ? message.seq : void 0;
				const terminalSeq = this.#terminalPauseSeq;
				if (terminalSeq === null || eventSeq == null || eventSeq > terminalSeq) {
					if (this.#terminalPauseTimer != null) {
						clearTimeout(this.#terminalPauseTimer);
						this.#terminalPauseTimer = void 0;
					}
					for (const [id, subscription] of this.#subscriptions) if (id !== this.#lifecycleSubId) subscription.resume();
					this.#scheduleTerminalPause(terminalSeq === null ? void 0 : terminalSeq);
				}
			}
			if (fannedToAny && message.method === "lifecycle" && message.params.namespace.length === 0 && TERMINAL_LIFECYCLE_EVENTS.has(message.params.data.event)) {
				if (message.params.data.event === "interrupted" && this.#headlessInterruptsAwaitingTerminal.size > 0) {
					this.#headlessInterruptsAwaitingTerminal.clear();
					return;
				}
				this.#scheduleTerminalPause(typeof message.seq === "number" ? message.seq : void 0);
			}
			return;
		}
		const messageId = typeof message.id === "number" ? message.id : void 0;
		const pending = messageId === void 0 ? void 0 : this.#pending.get(messageId);
		if (!pending) {
			if (message.type === "error" && message.id == null) this.#fireOnError(new ProtocolError(message));
			return;
		}
		if (messageId !== void 0) this.#pending.delete(messageId);
		if (message.type === "error") {
			pending.reject(new ProtocolError(message));
			return;
		}
		if (typeof message.meta?.applied_through_seq === "number") this.ordering.lastAppliedThroughSeq = message.meta.applied_through_seq;
		pending.resolve(message);
	}
	async #send(method, params) {
		await this.#ensureOpen();
		const id = this.#nextCommandId++;
		const command = {
			id,
			method,
			params
		};
		const responsePromise = new Promise((resolve, reject) => {
			this.#pending.set(id, {
				resolve,
				reject
			});
		});
		let immediate;
		try {
			immediate = await this.#transportAdapter.send(command);
		} catch (error) {
			this.#pending.delete(id);
			throw error;
		}
		if (immediate) {
			this.#pending.delete(id);
			if (immediate.type === "error") throw new ProtocolError(immediate);
			if (typeof immediate.meta?.applied_through_seq === "number") this.ordering.lastAppliedThroughSeq = immediate.meta.applied_through_seq;
			return immediate.result;
		}
		return (await responsePromise).result;
	}
};
//#endregion
export { SubscriptionHandle, ThreadStream };

//# sourceMappingURL=index.js.map