/**
 * High-level facade for the Agent Relay SDK.
 *
 * Provides a clean, property-based API on top of the lower-level
 * {@link AgentRelayClient} protocol client.
 *
 * @example
 * ```ts
 * import { AgentRelay } from "@agent-relay/sdk";
 *
 * const relay = new AgentRelay();
 *
 * relay.onMessageReceived = (message) => console.log(message);
 * relay.onAgentSpawned = (agent) => console.log("spawned", agent.name);
 *
 * const codex = await relay.codex.spawn();
 * const human = relay.human({ name: "System" });
 * await human.sendMessage({ to: codex.name, text: "Hello!" });
 *
 * const agents = await relay.listAgents();
 * for (const a of agents) await a.release();
 * await relay.shutdown();
 * ```
 */
import { randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { RelayCast } from '@relaycast/sdk';
import { AgentRelayClient } from './client.js';
import { buildPersonaSpawnSpec, composePersonaTask, loadPersona, materializePersonaConfigFiles, restorePersonaConfigFiles, } from './personas.js';
import { AgentRelayProtocolError } from './transport.js';
import { followLogs as followLogsFromFile, getLogs as getLogsFromFile, listLoggedAgents as listLoggedAgentsFromFile, } from './logs.js';
function isUnsupportedOperation(error) {
    return error instanceof AgentRelayProtocolError && error.code === 'unsupported_operation';
}
function buildUnsupportedOperationMessage(from, input) {
    return {
        eventId: 'unsupported_operation',
        from,
        to: input.to,
        text: input.text,
        threadId: input.threadId,
        data: input.data,
        mode: input.mode,
    };
}
const WORKSPACE_ID_PREFIX = 'rw_';
const WORKSPACE_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
function normalizeWorkspaceId(value) {
    const trimmed = value?.trim();
    return trimmed ? trimmed : undefined;
}
function generateWorkspaceId() {
    const alphabetLength = WORKSPACE_ID_ALPHABET.length;
    const maxUnbiasedValue = Math.floor(256 / alphabetLength) * alphabetLength;
    let suffix = '';
    while (suffix.length < 8) {
        const bytes = randomBytes(8 - suffix.length);
        for (const byte of bytes) {
            if (byte >= maxUnbiasedValue)
                continue;
            suffix += WORKSPACE_ID_ALPHABET[byte % alphabetLength];
            if (suffix.length === 8)
                break;
        }
    }
    return `${WORKSPACE_ID_PREFIX}${suffix}`;
}
function toWorkspaceRegistryEntry(value) {
    if (!value || typeof value !== 'object') {
        return {};
    }
    const record = value;
    const relaycastApiKey = typeof record.relaycastApiKey === 'string' && record.relaycastApiKey.trim()
        ? record.relaycastApiKey.trim()
        : undefined;
    const relayfileUrl = typeof record.relayfileUrl === 'string' && record.relayfileUrl.trim()
        ? record.relayfileUrl.trim()
        : undefined;
    const createdAt = typeof record.createdAt === 'string' && record.createdAt.trim() ? record.createdAt.trim() : undefined;
    const agents = Array.isArray(record.agents)
        ? record.agents
            .filter((agent) => typeof agent === 'string')
            .map((agent) => agent.trim())
            .filter((agent) => agent.length > 0)
        : undefined;
    return {
        ...(relaycastApiKey ? { relaycastApiKey } : {}),
        ...(relayfileUrl ? { relayfileUrl } : {}),
        ...(createdAt ? { createdAt } : {}),
        ...(agents && agents.length > 0 ? { agents } : {}),
    };
}
// ── AgentRelay facade ───────────────────────────────────────────────────────
export class AgentRelay {
    // Event hooks — assign a callback or null to clear.
    onMessageReceived = null;
    onMessageSent = null;
    onAgentSpawned = null;
    onAgentReleased = null;
    onAgentExited = null;
    onAgentReady = null;
    onWorkerOutput = null;
    onDeliveryUpdate = null;
    onAgentExitRequested = null;
    onAgentIdle = null;
    onAgentActivityChanged = null;
    onChannelSubscribed = null;
    onChannelUnsubscribed = null;
    // ── Public accessors ────────────────────────────────────────────────────
    /** The resolved Relaycast workspace API key (available after first spawn). */
    get workspaceKey() {
        return this.relayApiKey;
    }
    /** Observer URL for the auto-created workspace (available after first spawn). */
    get observerUrl() {
        if (!this.relayApiKey)
            return undefined;
        return `https://agentrelay.com/observer?key=${this.relayApiKey}`;
    }
    // Shorthand spawners
    codex;
    claude;
    gemini;
    opencode;
    clientOptions;
    defaultChannels;
    requestedWorkspaceId;
    workspaceName;
    relaycastBaseUrl;
    defaultPersonaDirs;
    relayApiKey;
    resolvedWorkspaceId;
    client;
    startPromise;
    unsubEvent;
    stderrListeners = new Set();
    knownAgents = new Map();
    readyAgents = new Set();
    messageReadyAgents = new Set();
    exitedAgents = new Set();
    idleAgents = new Set();
    deliveryStates = new Map();
    agentActivityStates = new Map();
    outputListeners = new Map();
    exitResolvers = new Map();
    exitResolverSeq = 0;
    idleResolvers = new Map();
    idleResolverSeq = 0;
    constructor(options = {}) {
        const requestedWorkspaceId = normalizeWorkspaceId(options.workspaceId);
        this.defaultChannels = options.channels ?? ['general'];
        this.requestedWorkspaceId = requestedWorkspaceId;
        this.workspaceName = options.workspaceName;
        if (options.workspaceName && !options.workspaceId) {
            console.warn('[AgentRelay] workspaceName without workspaceId is deprecated and will be removed in a future major version. ' +
                'Set workspaceId explicitly to avoid silent behavior changes.');
        }
        this.relaycastBaseUrl = options.relaycastBaseUrl;
        if (options.personaDirs)
            this.defaultPersonaDirs = [...options.personaDirs];
        this.clientOptions = {
            binaryPath: options.binaryPath,
            binaryArgs: options.binaryArgs,
            brokerName: options.brokerName ?? options.workspaceName ?? requestedWorkspaceId,
            channels: this.defaultChannels,
            cwd: options.cwd,
            env: options.env,
            requestTimeoutMs: options.requestTimeoutMs,
        };
        this.codex = this.createSpawner('codex', 'Codex', 'pty');
        this.claude = this.createSpawner('claude', 'Claude', 'pty');
        this.gemini = this.createSpawner('gemini', 'Gemini', 'pty');
        this.opencode = this.createSpawner('opencode', 'OpenCode', 'headless');
    }
    getWorkspaceRegistryPath() {
        return path.join(this.clientOptions.cwd ?? process.cwd(), '.relay', 'workspaces.json');
    }
    readWorkspaceRegistry() {
        const registryPath = this.getWorkspaceRegistryPath();
        if (!existsSync(registryPath)) {
            return {};
        }
        let raw;
        try {
            raw = readFileSync(registryPath, 'utf8').trim();
        }
        catch {
            return {};
        }
        if (!raw) {
            return {};
        }
        let parsed;
        try {
            parsed = JSON.parse(raw);
        }
        catch {
            // Registry file is corrupted (partial write, disk full, concurrent access).
            // Return empty registry so callers can re-create it.
            return {};
        }
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
            return {};
        }
        const registry = {};
        for (const [workspaceId, entry] of Object.entries(parsed)) {
            const normalizedId = normalizeWorkspaceId(workspaceId);
            if (!normalizedId)
                continue;
            registry[normalizedId] = toWorkspaceRegistryEntry(entry);
        }
        return registry;
    }
    writeWorkspaceRegistry(registry) {
        const registryPath = this.getWorkspaceRegistryPath();
        mkdirSync(path.dirname(registryPath), { recursive: true });
        writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
    }
    persistWorkspaceMapping(workspaceId, apiKey) {
        const registry = this.readWorkspaceRegistry();
        const existing = registry[workspaceId] ?? {};
        registry[workspaceId] = {
            ...existing,
            relaycastApiKey: apiKey,
            relayfileUrl: existing.relayfileUrl,
            createdAt: existing.createdAt ?? new Date().toISOString(),
            agents: existing.agents ?? [],
        };
        this.writeWorkspaceRegistry(registry);
    }
    findMappedWorkspaceIdByApiKey(apiKey) {
        const registry = this.readWorkspaceRegistry();
        for (const [workspaceId, entry] of Object.entries(registry)) {
            if (entry.relaycastApiKey === apiKey) {
                return workspaceId;
            }
        }
        return undefined;
    }
    getResolvedWorkspaceId() {
        return this.resolvedWorkspaceId ?? this.requestedWorkspaceId;
    }
    getRelaycastBaseUrl() {
        return (this.relaycastBaseUrl ??
            this.clientOptions.env?.RELAYCAST_BASE_URL ??
            process.env.RELAYCAST_BASE_URL ??
            'https://api.relaycast.dev');
    }
    applyWorkspaceEnv(workspaceId, apiKey) {
        // `workspaceId` here is the **relaycast** workspace id resolved by this
        // SDK (auto-created or caller-supplied via `new AgentRelay({ workspaceId })`).
        // Do NOT write it into `RELAYFILE_WORKSPACE` — relayfile and relaycast
        // workspaces are independent ids and a relayfile JWT scoped to a
        // different workspace will 403 with "workspace mismatch". Callers that
        // share an id across both services (e.g. the canonical `relay on start`
        // flow) set `RELAYFILE_WORKSPACE` themselves.
        const env = {
            ...(this.clientOptions.env ?? process.env),
            RELAY_API_KEY: apiKey,
            RELAY_DEFAULT_WORKSPACE: workspaceId,
            RELAY_WORKSPACE_ID: workspaceId,
            RELAY_WORKSPACES_JSON: JSON.stringify([{ workspace_id: workspaceId, api_key: apiKey }]),
        };
        if (this.relaycastBaseUrl) {
            env.RELAYCAST_BASE_URL = this.relaycastBaseUrl;
        }
        this.clientOptions.env = env;
    }
    async createMappedRelaycastWorkspace(workspaceId) {
        const created = await RelayCast.createWorkspace(this.workspaceName ?? workspaceId, this.getRelaycastBaseUrl());
        const workspace = created;
        const apiKey = workspace.apiKey ?? workspace.api_key;
        if (!apiKey) {
            throw new Error('RelayCast.createWorkspace() did not return an API key');
        }
        return apiKey;
    }
    /**
     * Subscribe to broker stderr output. Listener is wired immediately if the
     * client is already started, otherwise it is attached when the client starts.
     * Returns an unsubscribe function.
     */
    onBrokerStderr(listener) {
        this.stderrListeners.add(listener);
        return () => {
            this.stderrListeners.delete(listener);
        };
    }
    // ── Spawning ────────────────────────────────────────────────────────────
    async spawnPty(input) {
        const client = await this.ensureStarted();
        if (!input.channels || input.channels.length === 0) {
            console.warn(`[AgentRelay] spawnPty("${input.name}"): no channels specified, defaulting to "general". ` +
                'Set explicit channels for workflow isolation.');
        }
        const channels = input.channels ?? ['general'];
        const lifecycleContext = {
            name: input.name,
            cli: input.cli,
            channels,
            task: input.task,
        };
        await this.invokeLifecycleHook(input.onStart, lifecycleContext, `spawnPty("${input.name}") onStart`);
        let result;
        try {
            result = await client.spawnPty({
                name: input.name,
                cli: input.cli,
                args: input.args,
                channels,
                task: input.task,
                model: input.model,
                cwd: input.cwd,
                team: input.team,
                agentToken: input.agentToken,
                shadowOf: input.shadowOf,
                shadowMode: input.shadowMode,
                idleThresholdSecs: input.idleThresholdSecs,
                restartPolicy: input.restartPolicy,
                skipRelayPrompt: input.skipRelayPrompt,
            });
        }
        catch (error) {
            await this.invokeLifecycleHook(input.onError, {
                ...lifecycleContext,
                error,
            }, `spawnPty("${input.name}") onError`);
            throw error;
        }
        this.resetAgentLifecycleState(result.name);
        const agent = this.makeAgent(result.name, result.runtime, channels);
        this.knownAgents.set(agent.name, agent);
        await this.invokeLifecycleHook(input.onSuccess, {
            ...lifecycleContext,
            name: result.name,
            runtime: result.runtime,
        }, `spawnPty("${input.name}") onSuccess`);
        return agent;
    }
    async spawn(name, cli, task, options) {
        return this.spawnPty({
            name,
            cli,
            task,
            args: options?.args,
            channels: options?.channels,
            model: options?.model,
            cwd: options?.cwd,
            team: options?.team,
            agentToken: options?.agentToken,
            shadowOf: options?.shadowOf,
            shadowMode: options?.shadowMode,
            idleThresholdSecs: options?.idleThresholdSecs,
            restartPolicy: options?.restartPolicy,
            skipRelayPrompt: options?.skipRelayPrompt,
            onStart: options?.onStart,
            onSuccess: options?.onSuccess,
            onError: options?.onError,
        });
    }
    async spawnAndWait(name, cli, task, options) {
        const { timeoutMs, waitForMessage, ...spawnOptions } = options ?? {};
        await this.spawn(name, cli, task, spawnOptions);
        if (waitForMessage) {
            return this.waitForAgentMessage(name, timeoutMs ?? 60_000);
        }
        return this.waitForAgentReady(name, timeoutMs ?? 60_000);
    }
    /**
     * Spawn an agent from a named AgentWorkforce persona.
     *
     * Looks up the persona JSON in the search-dir cascade
     * (`<cwd>/agentworkforce/personas`, `<cwd>/.agentworkforce/workforce/personas`,
     * `~/.agentworkforce/workforce/personas`, plus `AGENT_WORKFORCE_HOME`),
     * resolves the requested tier, and translates it to spawnPty args via
     * `@agentworkforce/harness-kit#buildInteractiveSpec`.
     *
     * For opencode, an `opencode.json` is materialized in the spawn cwd and
     * automatically restored when the agent exits. For codex, the persona's
     * systemPrompt is folded into the initial task (codex has no
     * system-prompt flag). Translation warnings are surfaced via console.warn.
     *
     * @param personaId — id of the persona to load
     * @param options — overrides for tier, search dirs, name, task, and the
     *   underlying spawn options
     */
    async spawnPersona(personaId, options = {}) {
        const personaCwd = options.personaCwd ?? options.cwd ?? process.cwd();
        const searchDirs = options.searchDirs ?? this.defaultPersonaDirs;
        const loadOpts = {
            cwd: personaCwd,
            ...(searchDirs ? { searchDirs } : {}),
            ...(options.extraDirs ? { extraDirs: options.extraDirs } : {}),
            ...(options.tier ? { tier: options.tier } : {}),
        };
        const persona = options.persona ?? loadPersona(personaId, loadOpts);
        const spec = buildPersonaSpawnSpec(persona);
        for (const warning of spec.warnings) {
            console.warn(`[AgentRelay] ${warning}`);
        }
        const spawnCwd = options.cwd ?? process.cwd();
        const writes = spec.configFiles.length > 0 ? materializePersonaConfigFiles(spawnCwd, spec.configFiles) : [];
        const baseArgs = options.args ?? [];
        const mergedArgs = [...spec.args, ...baseArgs];
        const task = composePersonaTask(spec, options.task);
        const spawnName = options.name ?? persona.id;
        let agent;
        try {
            agent = await this.spawnPty({
                name: spawnName,
                cli: spec.cli,
                args: mergedArgs,
                ...(task !== undefined ? { task } : {}),
                channels: options.channels,
                model: spec.model,
                cwd: spawnCwd,
                team: options.team,
                agentToken: options.agentToken,
                shadowOf: options.shadowOf,
                shadowMode: options.shadowMode,
                idleThresholdSecs: options.idleThresholdSecs,
                restartPolicy: options.restartPolicy,
                skipRelayPrompt: options.skipRelayPrompt,
                onStart: options.onStart,
                onSuccess: options.onSuccess,
                onError: options.onError,
            });
        }
        catch (err) {
            restorePersonaConfigFiles(writes);
            throw err;
        }
        if (writes.length > 0) {
            void agent.waitForExit().finally(() => {
                restorePersonaConfigFiles(writes);
            });
        }
        return agent;
    }
    // ── Human source ────────────────────────────────────────────────────────
    human(opts) {
        return {
            name: opts.name,
            sendMessage: async (input) => {
                const client = await this.ensureStarted();
                let result;
                try {
                    result = await client.sendMessage({
                        to: input.to,
                        text: input.text,
                        from: opts.name,
                        threadId: input.threadId,
                        priority: input.priority,
                        data: input.data,
                        mode: input.mode,
                    });
                }
                catch (error) {
                    if (isUnsupportedOperation(error)) {
                        return buildUnsupportedOperationMessage(opts.name, input);
                    }
                    throw error;
                }
                if (result?.event_id === 'unsupported_operation') {
                    return buildUnsupportedOperationMessage(opts.name, input);
                }
                const eventId = result?.event_id ?? randomBytes(8).toString('hex');
                const msg = {
                    eventId,
                    from: opts.name,
                    to: input.to,
                    text: input.text,
                    threadId: input.threadId,
                    data: input.data,
                    mode: input.mode,
                };
                this.onMessageSent?.(msg);
                return msg;
            },
        };
    }
    system() {
        return this.human({ name: 'system' });
    }
    // ── Messaging ─────────────────────────────────────────────────────────
    /**
     * Broadcast a message to all connected agents.
     * @param text — the message body
     * @param options — optional sender name (defaults to "human:orchestrator")
     */
    async broadcast(text, options) {
        const from = options?.from ?? 'human:orchestrator';
        return this.human({ name: from }).sendMessage({ to: '*', text });
    }
    async sendAndWaitForDelivery(input, timeoutMs = 30_000) {
        const client = await this.ensureStarted();
        const result = await client.sendMessage(input);
        if (!result.targets.length) {
            return { eventId: result.event_id, status: 'failed', targets: [] };
        }
        return new Promise((resolve) => {
            let resolved = false;
            const ackedTargets = new Set();
            // eslint-disable-next-line prefer-const
            let unsubscribe;
            const timer = setTimeout(() => {
                if (!resolved) {
                    resolved = true;
                    unsubscribe?.();
                    resolve({ eventId: result.event_id, status: 'timeout', targets: result.targets });
                }
            }, timeoutMs);
            unsubscribe = client.onEvent((event) => {
                if (resolved)
                    return;
                if (event.kind === 'delivery_ack' &&
                    event.event_id === result.event_id &&
                    result.targets.includes(event.name)) {
                    ackedTargets.add(event.name);
                    if (ackedTargets.size >= result.targets.length) {
                        resolved = true;
                        clearTimeout(timer);
                        unsubscribe?.();
                        resolve({ eventId: result.event_id, status: 'ack', targets: result.targets });
                    }
                }
                if (event.kind === 'delivery_failed' &&
                    event.event_id === result.event_id &&
                    result.targets.includes(event.name)) {
                    resolved = true;
                    clearTimeout(timer);
                    unsubscribe?.();
                    resolve({ eventId: result.event_id, status: 'failed', targets: result.targets });
                }
            });
        });
    }
    // ── Listing ─────────────────────────────────────────────────────────────
    async listAgents() {
        const client = await this.ensureStarted();
        const list = await client.listAgents();
        return list.map((entry) => {
            const existing = this.knownAgents.get(entry.name);
            if (existing)
                return existing;
            const agent = this.makeAgent(entry.name, entry.runtime, entry.channels);
            this.knownAgents.set(agent.name, agent);
            return agent;
        });
    }
    /** Pre-register a batch of agents with Relaycast before steps execute. */
    async preflightAgents(agents) {
        const client = await this.ensureStarted();
        await client.preflight(agents);
    }
    /** List agents with PIDs from the broker (for worker registration). */
    async listAgentsRaw() {
        const client = await this.ensureStarted();
        return client.listAgents();
    }
    // ── Status ────────────────────────────────────────────────────────────
    async getStatus() {
        const client = await this.ensureStarted();
        return client.getStatus();
    }
    async subscribe(opts) {
        const client = await this.ensureStarted();
        await client.subscribeChannels(opts.agent, opts.channels);
        this.addAgentChannels(opts.agent, opts.channels);
    }
    async unsubscribe(opts) {
        const client = await this.ensureStarted();
        await client.unsubscribeChannels(opts.agent, opts.channels);
        this.removeAgentChannels(opts.agent, opts.channels);
    }
    getDeliveryState(eventId) {
        return this.deliveryStates.get(eventId);
    }
    // ── Logs ──────────────────────────────────────────────────────────────
    /**
     * Read the last N lines of an agent's log file.
     *
     * @example
     * ```ts
     * const logs = await relay.getLogs("Worker1", { lines: 100 });
     * if (logs.found) console.log(logs.content);
     * ```
     */
    async getLogs(agentName, options) {
        const cwd = this.clientOptions.cwd ?? process.cwd();
        const logsDir = path.join(cwd, '.agent-relay', 'team', 'worker-logs');
        return getLogsFromFile(agentName, { logsDir, lines: options?.lines });
    }
    /** List all agents that have log files. */
    async listLoggedAgents() {
        const cwd = this.clientOptions.cwd ?? process.cwd();
        const logsDir = path.join(cwd, '.agent-relay', 'team', 'worker-logs');
        return listLoggedAgentsFromFile(logsDir);
    }
    /**
     * Follow an agent's local log file with history bootstrap + incremental updates.
     *
     * @example
     * ```ts
     * const handle = relay.followLogs("Worker1", {
     *   historyLines: 100,
     *   onEvent(event) {
     *     if (event.type === "log") console.log(event.content);
     *   },
     * });
     *
     * // Later:
     * handle.unsubscribe();
     * ```
     */
    followLogs(agentName, options) {
        const cwd = this.clientOptions.cwd ?? process.cwd();
        const logsDir = path.join(cwd, '.agent-relay', 'team', 'worker-logs');
        return followLogsFromFile(agentName, { ...options, logsDir });
    }
    // ── Wait helpers ──────────────────────────────────────────────────────
    /**
     * Wait for any one of the given agents to exit. Returns the first agent
     * that exits along with its exit reason.
     *
     * @example
     * ```ts
     * const { agent, result } = await AgentRelay.waitForAny([worker1, worker2], 60_000);
     * console.log(`${agent.name} finished: ${result}`);
     * ```
     */
    static async waitForAny(agents, timeoutMs) {
        if (agents.length === 0) {
            throw new Error('waitForAny requires at least one agent');
        }
        return Promise.race(agents.map(async (agent) => {
            const result = await agent.waitForExit(timeoutMs);
            return { agent, result };
        }));
    }
    /**
     * Resolves when the agent process has started and connected to the broker.
     * The agent's CLI may not yet be ready to receive messages.
     * Use `waitForAgentMessage()` for full readiness.
     */
    async waitForAgentReady(name, timeoutMs = 60_000) {
        const client = await this.ensureStarted();
        const existing = this.knownAgents.get(name);
        if (existing && this.readyAgents.has(name)) {
            return existing;
        }
        return new Promise((resolve, reject) => {
            let settled = false;
            let timeout;
            const cleanup = () => {
                unsubscribe();
                if (timeout) {
                    clearTimeout(timeout);
                    timeout = undefined;
                }
            };
            const resolveWith = (agent) => {
                if (settled)
                    return;
                settled = true;
                cleanup();
                resolve(agent);
            };
            const rejectWith = (error) => {
                if (settled)
                    return;
                settled = true;
                cleanup();
                reject(error);
            };
            const unsubscribe = client.onEvent((event) => {
                if (event.kind !== 'worker_ready' || event.name !== name) {
                    return;
                }
                const agent = this.ensureAgentHandle(event.name, event.runtime);
                this.readyAgents.add(event.name);
                this.exitedAgents.delete(event.name);
                resolveWith(agent);
            });
            timeout = setTimeout(() => {
                rejectWith(new Error(`Timed out waiting for worker_ready for '${name}' after ${timeoutMs}ms`));
            }, timeoutMs);
            const known = this.knownAgents.get(name);
            if (known && this.readyAgents.has(name)) {
                resolveWith(known);
            }
        });
    }
    async waitForAgentMessage(name, timeoutMs = 60_000) {
        const client = await this.ensureStarted();
        const existing = this.knownAgents.get(name);
        if (existing && this.messageReadyAgents.has(name)) {
            return existing;
        }
        return new Promise((resolve, reject) => {
            let settled = false;
            let timeout;
            const cleanup = () => {
                unsubscribe();
                if (timeout) {
                    clearTimeout(timeout);
                    timeout = undefined;
                }
            };
            const resolveWith = (agent) => {
                if (settled)
                    return;
                settled = true;
                cleanup();
                resolve(agent);
            };
            const rejectWith = (error) => {
                if (settled)
                    return;
                settled = true;
                cleanup();
                reject(error);
            };
            const unsubscribe = client.onEvent((event) => {
                if (event.kind === 'relay_inbound' && event.from === name) {
                    this.messageReadyAgents.add(name);
                    this.exitedAgents.delete(name);
                    resolveWith(this.ensureAgentHandle(name));
                    return;
                }
                if (event.kind === 'agent_exited' && event.name === name) {
                    rejectWith(new Error(`Agent '${name}' exited before sending its first relay message`));
                    return;
                }
                if (event.kind === 'agent_released' && event.name === name) {
                    rejectWith(new Error(`Agent '${name}' was released before sending its first relay message`));
                }
            });
            timeout = setTimeout(() => {
                rejectWith(new Error(`Timed out waiting for first relay message from '${name}' after ${timeoutMs}ms`));
            }, timeoutMs);
            const known = this.knownAgents.get(name);
            if (known && this.messageReadyAgents.has(name)) {
                resolveWith(known);
            }
        });
    }
    // ── Lifecycle ───────────────────────────────────────────────────────────
    async shutdown() {
        if (this.unsubEvent) {
            this.unsubEvent();
            this.unsubEvent = undefined;
        }
        let client = this.client;
        if (!client && this.startPromise) {
            try {
                client = await this.startPromise;
            }
            catch {
                client = undefined;
            }
        }
        if (client) {
            await client.shutdown();
            if (this.client === client) {
                this.client = undefined;
            }
        }
        this.startPromise = undefined;
        this.knownAgents.clear();
        this.readyAgents.clear();
        this.messageReadyAgents.clear();
        this.exitedAgents.clear();
        this.idleAgents.clear();
        this.deliveryStates.clear();
        this.agentActivityStates.clear();
        this.outputListeners.clear();
        for (const entry of this.exitResolvers.values()) {
            entry.resolve('released');
        }
        this.exitResolvers.clear();
        for (const entry of this.idleResolvers.values()) {
            entry.resolve('exited');
        }
        this.idleResolvers.clear();
    }
    // ── Private helpers ─────────────────────────────────────────────────────
    ensureAgentHandle(name, runtime = 'pty', channels = []) {
        const existing = this.knownAgents.get(name);
        if (existing) {
            return existing;
        }
        const agent = this.makeAgent(name, runtime, channels);
        this.knownAgents.set(name, agent);
        return agent;
    }
    updateDeliveryState(eventId, to, status, updatedAt) {
        this.deliveryStates.set(eventId, { eventId, to, status, updatedAt });
    }
    ensureAgentActivityState(name) {
        const existing = this.agentActivityStates.get(name);
        if (existing) {
            return existing;
        }
        const state = {
            active: false,
            pendingDeliveries: new Map(),
        };
        this.agentActivityStates.set(name, state);
        return state;
    }
    getDeliveryActivityKey(deliveryId, eventId) {
        return deliveryId || eventId;
    }
    markAgentDeliveryPending(name, deliveryId, eventId, reason) {
        const state = this.ensureAgentActivityState(name);
        state.pendingDeliveries.set(this.getDeliveryActivityKey(deliveryId, eventId), eventId);
        this.setAgentActivity(name, state, state.pendingDeliveries.size > 0, reason, eventId);
    }
    closeAgentDelivery(name, reason, eventId, deliveryId) {
        const state = this.agentActivityStates.get(name);
        if (!state)
            return;
        const key = deliveryId && eventId ? this.getDeliveryActivityKey(deliveryId, eventId) : undefined;
        if (key) {
            state.pendingDeliveries.delete(key);
        }
        else if (eventId) {
            const matchingEntry = Array.from(state.pendingDeliveries.entries()).find(([, pendingEventId]) => {
                return pendingEventId === eventId;
            });
            if (matchingEntry) {
                state.pendingDeliveries.delete(matchingEntry[0]);
            }
            else {
                const oldestKey = state.pendingDeliveries.keys().next().value;
                if (oldestKey) {
                    state.pendingDeliveries.delete(oldestKey);
                }
            }
        }
        this.setAgentActivity(name, state, state.pendingDeliveries.size > 0, reason, eventId);
    }
    clearAgentDeliveries(name, reason, eventId) {
        const state = this.agentActivityStates.get(name);
        if (!state)
            return;
        state.pendingDeliveries.clear();
        this.setAgentActivity(name, state, false, reason, eventId);
    }
    setAgentActivity(name, state, active, reason, eventId) {
        if (state.active === active) {
            return;
        }
        state.active = active;
        this.onAgentActivityChanged?.({
            name,
            active,
            pendingDeliveries: state.pendingDeliveries.size,
            reason,
            eventId,
        });
    }
    resolveEventTimestamp(candidate) {
        return typeof candidate === 'number' ? candidate : Date.now();
    }
    addAgentChannels(name, channels) {
        const agent = this.knownAgents.get(name);
        if (!agent || channels.length === 0)
            return;
        const next = [...new Set([...agent.channels, ...channels])];
        agent._setChannels(next);
    }
    removeAgentChannels(name, channels) {
        const agent = this.knownAgents.get(name);
        if (!agent || channels.length === 0)
            return;
        const removed = new Set(channels);
        agent._setChannels(agent.channels.filter((channel) => !removed.has(channel)));
    }
    /** Resolve a target to a channel name. If `to` is `#channel`, use that
     *  channel. If it's a known agent name, use the agent's first channel.
     *  Otherwise fall back to the relay's default channel. */
    resolveChannel(to) {
        if (to.startsWith('#'))
            return to.slice(1);
        const agent = this.knownAgents.get(to);
        if (agent && agent.channels.length > 0)
            return agent.channels[0];
        return this.defaultChannels[0];
    }
    inferOutputMode(callback) {
        const source = callback.toString().trim().replace(/\s+/g, ' ');
        if (source.startsWith('({') || source.startsWith('async ({') || source.startsWith('function ({')) {
            return 'structured';
        }
        return 'chunk';
    }
    dispatchOutput(name, stream, chunk) {
        const listeners = this.outputListeners.get(name);
        if (!listeners)
            return;
        for (const listener of listeners) {
            if (listener.stream !== undefined && listener.stream !== stream)
                continue;
            if (listener.mode === 'structured') {
                listener.callback({ stream, chunk });
            }
            else {
                listener.callback(chunk);
            }
        }
    }
    /**
     * Ensure a Relaycast workspace API key is available.
     * Resolution order:
     *   1. Already resolved (cached from a previous call)
     *   2. RELAY_API_KEY in options.env
     *   3. RELAY_API_KEY in process.env
     *   4. Auto-create a fresh workspace via the Relaycast REST API
     */
    async ensureRelaycastApiKey() {
        if (this.relayApiKey) {
            const workspaceId = this.getResolvedWorkspaceId();
            if (workspaceId) {
                this.applyWorkspaceEnv(workspaceId, this.relayApiKey);
                try {
                    this.persistWorkspaceMapping(workspaceId, this.relayApiKey);
                }
                catch {
                    /* non-critical */
                }
            }
            else {
                this.wireRelaycastBaseUrl();
            }
            return;
        }
        const envKey = this.clientOptions.env?.RELAY_API_KEY ?? process.env.RELAY_API_KEY;
        const requestedWorkspaceId = this.requestedWorkspaceId;
        if (requestedWorkspaceId) {
            const registry = this.readWorkspaceRegistry();
            const mappedKey = registry[requestedWorkspaceId]?.relaycastApiKey;
            const resolvedKey = mappedKey ?? envKey ?? (await this.createMappedRelaycastWorkspace(requestedWorkspaceId));
            this.relayApiKey = resolvedKey;
            this.resolvedWorkspaceId = requestedWorkspaceId;
            this.applyWorkspaceEnv(requestedWorkspaceId, resolvedKey);
            try {
                this.persistWorkspaceMapping(requestedWorkspaceId, resolvedKey);
            }
            catch {
                /* non-critical */
            }
            return;
        }
        const resolvedWorkspaceId = envKey
            ? (this.findMappedWorkspaceIdByApiKey(envKey) ?? generateWorkspaceId())
            : generateWorkspaceId();
        const resolvedKey = envKey ?? (await this.createMappedRelaycastWorkspace(resolvedWorkspaceId));
        this.relayApiKey = resolvedKey;
        this.resolvedWorkspaceId = resolvedWorkspaceId;
        this.applyWorkspaceEnv(resolvedWorkspaceId, resolvedKey);
        try {
            this.persistWorkspaceMapping(resolvedWorkspaceId, resolvedKey);
        }
        catch {
            /* non-critical */
        }
    }
    /** Inject relaycastBaseUrl into broker env. Explicit option wins over inherited env. */
    wireRelaycastBaseUrl() {
        if (this.relaycastBaseUrl && this.clientOptions.env) {
            this.clientOptions.env.RELAYCAST_BASE_URL = this.relaycastBaseUrl;
        }
    }
    async ensureStarted() {
        if (this.client)
            return this.client;
        if (this.startPromise)
            return this.startPromise;
        this.startPromise = this.ensureRelaycastApiKey()
            .then(() => AgentRelayClient.spawn({
            ...this.clientOptions,
            onStderr: (line) => {
                for (const listener of this.stderrListeners) {
                    try {
                        listener(line);
                    }
                    catch {
                        /* ignore */
                    }
                }
            },
        }))
            .then((c) => {
            // Use the workspace key the broker actually connected with.
            // This ensures SDK and workers are always on the same workspace.
            if (c.workspaceKey) {
                this.relayApiKey = c.workspaceKey;
                const workspaceId = this.getResolvedWorkspaceId();
                if (workspaceId) {
                    this.applyWorkspaceEnv(workspaceId, c.workspaceKey);
                    try {
                        this.persistWorkspaceMapping(workspaceId, c.workspaceKey);
                    }
                    catch {
                        /* non-critical */
                    }
                }
            }
            this.wireEvents(c);
            this.client = c;
            this.startPromise = undefined;
            return c;
        })
            .catch((err) => {
            this.client = undefined;
            this.startPromise = undefined;
            throw err;
        });
        return this.startPromise;
    }
    wireEvents(client) {
        // eslint-disable-next-line complexity
        this.unsubEvent = client.onEvent((event) => {
            switch (event.kind) {
                case 'relay_inbound': {
                    this.closeAgentDelivery(event.from, 'relay_inbound', event.event_id);
                    if (this.knownAgents.has(event.from)) {
                        this.messageReadyAgents.add(event.from);
                        this.exitedAgents.delete(event.from);
                    }
                    this.clearAgentDeliveries(event.from, 'relay_inbound', event.event_id);
                    const msg = {
                        eventId: event.event_id,
                        from: event.from,
                        to: event.target,
                        text: event.body,
                        threadId: event.thread_id,
                        mode: event.injection_mode ?? event.mode,
                    };
                    this.onMessageReceived?.(msg);
                    break;
                }
                case 'agent_spawned': {
                    const agent = this.ensureAgentHandle(event.name, event.runtime);
                    this.readyAgents.delete(event.name);
                    this.messageReadyAgents.delete(event.name);
                    this.exitedAgents.delete(event.name);
                    this.idleAgents.delete(event.name);
                    this.onAgentSpawned?.(agent);
                    break;
                }
                case 'agent_released': {
                    const agent = this.knownAgents.get(event.name) ?? this.ensureAgentHandle(event.name, 'pty', []);
                    this.clearAgentDeliveries(event.name, 'agent_released');
                    this.exitedAgents.add(event.name);
                    this.readyAgents.delete(event.name);
                    this.messageReadyAgents.delete(event.name);
                    this.idleAgents.delete(event.name);
                    this.onAgentReleased?.(agent);
                    this.knownAgents.delete(event.name);
                    this.outputListeners.delete(event.name);
                    this.exitResolvers.get(event.name)?.resolve('released');
                    this.exitResolvers.delete(event.name);
                    this.idleResolvers.get(event.name)?.resolve('exited');
                    this.idleResolvers.delete(event.name);
                    break;
                }
                case 'agent_exited': {
                    const agent = this.knownAgents.get(event.name) ?? this.ensureAgentHandle(event.name, 'pty', []);
                    this.clearAgentDeliveries(event.name, 'agent_exited');
                    this.exitedAgents.add(event.name);
                    this.readyAgents.delete(event.name);
                    this.messageReadyAgents.delete(event.name);
                    this.idleAgents.delete(event.name);
                    // Populate exit info before firing the hook
                    agent.exitCode = event.code;
                    agent.exitSignal = event.signal;
                    this.onAgentExited?.(agent);
                    this.knownAgents.delete(event.name);
                    this.outputListeners.delete(event.name);
                    this.exitResolvers.get(event.name)?.resolve('exited');
                    this.exitResolvers.delete(event.name);
                    this.idleResolvers.get(event.name)?.resolve('exited');
                    this.idleResolvers.delete(event.name);
                    break;
                }
                case 'agent_exit': {
                    const agent = this.knownAgents.get(event.name) ?? this.ensureAgentHandle(event.name, 'pty', []);
                    agent.exitReason = event.reason;
                    this.onAgentExitRequested?.({ name: event.name, reason: event.reason });
                    break;
                }
                case 'worker_ready': {
                    const agent = this.ensureAgentHandle(event.name, event.runtime);
                    this.readyAgents.add(event.name);
                    this.exitedAgents.delete(event.name);
                    this.idleAgents.delete(event.name);
                    this.onAgentReady?.(agent);
                    break;
                }
                case 'channel_subscribed': {
                    this.addAgentChannels(event.name, event.channels);
                    this.onChannelSubscribed?.(event.name, event.channels);
                    break;
                }
                case 'channel_unsubscribed': {
                    this.removeAgentChannels(event.name, event.channels);
                    this.onChannelUnsubscribed?.(event.name, event.channels);
                    break;
                }
                case 'delivery_queued': {
                    this.markAgentDeliveryPending(event.name, event.delivery_id, event.event_id, 'delivery_queued');
                    this.updateDeliveryState(event.event_id, event.name, 'queued', this.resolveEventTimestamp(event.timestamp));
                    break;
                }
                case 'delivery_injected': {
                    this.markAgentDeliveryPending(event.name, event.delivery_id, event.event_id, 'delivery_injected');
                    this.updateDeliveryState(event.event_id, event.name, 'injected', this.resolveEventTimestamp(event.timestamp));
                    break;
                }
                case 'delivery_active': {
                    this.markAgentDeliveryPending(event.name, event.delivery_id, event.event_id, 'delivery_active');
                    this.updateDeliveryState(event.event_id, event.name, 'active', this.resolveEventTimestamp());
                    break;
                }
                case 'delivery_verified': {
                    this.updateDeliveryState(event.event_id, event.name, 'verified', this.resolveEventTimestamp());
                    break;
                }
                case 'delivery_ack': {
                    // No-op for activity tracking. delivery_ack can arrive late, after
                    // relay_inbound / idle / exit already cleared activity, so re-adding
                    // pending state here would incorrectly flip the agent back to active.
                    break;
                }
                case 'delivery_failed': {
                    this.closeAgentDelivery(event.name, 'delivery_failed', event.event_id, event.delivery_id);
                    this.updateDeliveryState(event.event_id, event.name, 'failed', this.resolveEventTimestamp());
                    break;
                }
                case 'worker_stream': {
                    // Agent producing output is no longer idle
                    this.idleAgents.delete(event.name);
                    this.onWorkerOutput?.({
                        name: event.name,
                        stream: event.stream,
                        chunk: event.chunk,
                    });
                    // Dispatch to per-agent output listeners
                    this.dispatchOutput(event.name, event.stream, event.chunk);
                    break;
                }
                case 'agent_idle': {
                    this.clearAgentDeliveries(event.name, 'agent_idle');
                    this.idleAgents.add(event.name);
                    this.onAgentIdle?.({
                        name: event.name,
                        idleSecs: event.idle_secs,
                    });
                    // Resolve idle waiters
                    this.idleResolvers.get(event.name)?.resolve('idle');
                    this.idleResolvers.delete(event.name);
                    break;
                }
            }
            if (event.kind.startsWith('delivery_')) {
                this.onDeliveryUpdate?.(event);
            }
        });
    }
    makeAgent(name, runtime, channels) {
        // eslint-disable-next-line @typescript-eslint/no-this-alias
        const relay = this;
        let agentChannels = [...channels];
        const agent = {
            name,
            runtime,
            get channels() {
                return [...agentChannels];
            },
            get status() {
                if (relay.exitedAgents.has(name))
                    return 'exited';
                if (relay.idleAgents.has(name))
                    return 'idle';
                if (relay.readyAgents.has(name))
                    return 'ready';
                return 'spawning';
            },
            exitCode: undefined,
            exitSignal: undefined,
            async release(reasonOrOptions) {
                const releaseOptions = relay.normalizeReleaseOptions(reasonOrOptions);
                const releaseContext = {
                    name,
                    reason: releaseOptions.reason,
                };
                if (!relay.knownAgents.has(name)) {
                    await relay.invokeLifecycleHook(releaseOptions.onStart, releaseContext, `release("${name}") onStart`);
                    await relay.invokeLifecycleHook(releaseOptions.onSuccess, releaseContext, `release("${name}") onSuccess`);
                    return;
                }
                const client = await relay.ensureStarted();
                await relay.invokeLifecycleHook(releaseOptions.onStart, releaseContext, `release("${name}") onStart`);
                try {
                    await client.release(name, releaseOptions.reason);
                    await relay.invokeLifecycleHook(releaseOptions.onSuccess, releaseContext, `release("${name}") onSuccess`);
                }
                catch (error) {
                    if (error instanceof AgentRelayProtocolError && error.code === 'agent_not_found') {
                        relay.exitedAgents.add(name);
                        relay.readyAgents.delete(name);
                        relay.messageReadyAgents.delete(name);
                        relay.idleAgents.delete(name);
                        relay.knownAgents.delete(name);
                        relay.outputListeners.delete(name);
                        relay.exitResolvers.get(name)?.resolve('released');
                        relay.exitResolvers.delete(name);
                        relay.idleResolvers.get(name)?.resolve('exited');
                        relay.idleResolvers.delete(name);
                        await relay.invokeLifecycleHook(releaseOptions.onSuccess, releaseContext, `release("${name}") onSuccess`);
                        return;
                    }
                    await relay.invokeLifecycleHook(releaseOptions.onError, {
                        ...releaseContext,
                        error,
                    }, `release("${name}") onError`);
                    throw error;
                }
            },
            async waitForReady(timeoutMs = 60_000) {
                await relay.waitForAgentReady(name, timeoutMs);
            },
            waitForExit(timeoutMs) {
                return new Promise((resolve) => {
                    // If already gone, resolve immediately
                    if (!relay.knownAgents.has(name)) {
                        resolve('exited');
                        return;
                    }
                    // Non-blocking poll: timeoutMs === 0 means "check now, return immediately"
                    if (timeoutMs === 0) {
                        resolve('timeout');
                        return;
                    }
                    let timer;
                    const token = ++relay.exitResolverSeq;
                    relay.exitResolvers.set(name, {
                        resolve(reason) {
                            if (timer)
                                clearTimeout(timer);
                            resolve(reason);
                        },
                        token,
                    });
                    if (timeoutMs !== undefined) {
                        timer = setTimeout(() => {
                            // Only delete if this is still our resolver (not one from a later call)
                            const current = relay.exitResolvers.get(name);
                            if (current?.token === token) {
                                relay.exitResolvers.delete(name);
                            }
                            resolve('timeout');
                        }, timeoutMs);
                    }
                });
            },
            waitForIdle(timeoutMs) {
                return new Promise((resolve) => {
                    if (!relay.knownAgents.has(name)) {
                        resolve('exited');
                        return;
                    }
                    if (timeoutMs === 0) {
                        resolve('timeout');
                        return;
                    }
                    let timer;
                    const token = ++relay.idleResolverSeq;
                    relay.idleResolvers.set(name, {
                        resolve(reason) {
                            if (timer)
                                clearTimeout(timer);
                            resolve(reason);
                        },
                        token,
                    });
                    if (timeoutMs !== undefined) {
                        timer = setTimeout(() => {
                            const current = relay.idleResolvers.get(name);
                            if (current?.token === token) {
                                relay.idleResolvers.delete(name);
                            }
                            resolve('timeout');
                        }, timeoutMs);
                    }
                });
            },
            async sendMessage(input) {
                const client = await relay.ensureStarted();
                let result;
                try {
                    result = await client.sendMessage({
                        to: input.to,
                        text: input.text,
                        from: name,
                        threadId: input.threadId,
                        priority: input.priority,
                        data: input.data,
                        mode: input.mode,
                    });
                }
                catch (error) {
                    if (isUnsupportedOperation(error)) {
                        return buildUnsupportedOperationMessage(name, input);
                    }
                    throw error;
                }
                if (result?.event_id === 'unsupported_operation') {
                    return buildUnsupportedOperationMessage(name, input);
                }
                const eventId = result?.event_id ?? randomBytes(8).toString('hex');
                const msg = {
                    eventId,
                    from: name,
                    to: input.to,
                    text: input.text,
                    threadId: input.threadId,
                    data: input.data,
                    mode: input.mode,
                };
                relay.onMessageSent?.(msg);
                return msg;
            },
            async subscribe(channelsToAdd) {
                await relay.subscribe({ agent: name, channels: channelsToAdd });
            },
            async unsubscribe(channelsToRemove) {
                await relay.unsubscribe({ agent: name, channels: channelsToRemove });
            },
            onOutput(callback, options) {
                let listeners = relay.outputListeners.get(name);
                if (!listeners) {
                    listeners = new Set();
                    relay.outputListeners.set(name, listeners);
                }
                const listener = {
                    callback,
                    mode: options?.mode ?? relay.inferOutputMode(callback),
                    stream: options?.stream,
                };
                listeners.add(listener);
                return () => {
                    listeners.delete(listener);
                    if (listeners.size === 0) {
                        relay.outputListeners.delete(name);
                    }
                };
            },
            _setChannels(nextChannels) {
                agentChannels = [...nextChannels];
            },
        };
        return agent;
    }
    createSpawner(cli, defaultName, runtime) {
        return {
            spawn: async (options) => {
                const name = options?.name ?? defaultName;
                const channels = options?.channels ?? ['general'];
                const args = options?.args ?? [];
                const task = options?.task;
                if (runtime === 'pty') {
                    return this.spawnPty({
                        name,
                        cli,
                        args,
                        channels,
                        task,
                        model: options?.model,
                        cwd: options?.cwd,
                        idleThresholdSecs: options?.idleThresholdSecs,
                        agentToken: options?.agentToken,
                        skipRelayPrompt: options?.skipRelayPrompt,
                        onStart: options?.onStart,
                        onSuccess: options?.onSuccess,
                        onError: options?.onError,
                    });
                }
                const client = await this.ensureStarted();
                const lifecycleContext = {
                    name,
                    cli,
                    channels,
                    task,
                };
                await this.invokeLifecycleHook(options?.onStart, lifecycleContext, `spawn("${name}") onStart`);
                let result;
                try {
                    result = await client.spawnProvider({
                        name,
                        provider: cli,
                        transport: 'headless',
                        args,
                        channels,
                        task,
                        model: options?.model,
                        cwd: options?.cwd,
                        idleThresholdSecs: options?.idleThresholdSecs,
                        agentToken: options?.agentToken,
                        skipRelayPrompt: options?.skipRelayPrompt,
                    });
                }
                catch (error) {
                    await this.invokeLifecycleHook(options?.onError, {
                        ...lifecycleContext,
                        error,
                    }, `spawn("${name}") onError`);
                    throw error;
                }
                this.resetAgentLifecycleState(result.name);
                const agent = this.makeAgent(result.name, result.runtime, channels);
                this.knownAgents.set(agent.name, agent);
                await this.invokeLifecycleHook(options?.onSuccess, {
                    ...lifecycleContext,
                    name: result.name,
                    runtime: result.runtime,
                }, `spawn("${name}") onSuccess`);
                return agent;
            },
        };
    }
    async invokeLifecycleHook(hook, context, label) {
        if (!hook) {
            return;
        }
        try {
            await hook(context);
        }
        catch (error) {
            console.warn(`[AgentRelay] ${label} hook threw`, error);
        }
    }
    resetAgentLifecycleState(name) {
        this.readyAgents.delete(name);
        this.messageReadyAgents.delete(name);
        this.exitedAgents.delete(name);
        this.idleAgents.delete(name);
        this.agentActivityStates.delete(name);
    }
    normalizeReleaseOptions(reasonOrOptions) {
        if (typeof reasonOrOptions === 'string' || reasonOrOptions === undefined) {
            return { reason: reasonOrOptions };
        }
        return reasonOrOptions;
    }
}
//# sourceMappingURL=relay.js.map