import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import { AttachPairingRegistry } from "./attach-auth.js";
const DEFAULT_MAX_COMMAND_BODY_BYTES = 64 * 1024;
export async function startAttachServer(runtime, options = {}) {
    const token = options.token ?? randomBytes(24).toString("base64url");
    const host = options.host ?? "127.0.0.1";
    const port = options.port ?? 0;
    const clients = new Set();
    const maxCommandBodyBytes = options.maxCommandBodyBytes ?? DEFAULT_MAX_COMMAND_BODY_BYTES;
    const pairing = new AttachPairingRegistry();
    const pairingMode = options.pairing ?? "off";
    let closed = false;
    const server = createServer((request, response) => {
        void handleAttachRequest(runtime, token, maxCommandBodyBytes, pairing, pairingMode, clients, request, response).catch((error) => {
            if (error instanceof AttachRequestTooLargeError) {
                writeJson(response, 413, { error: error.message });
                return;
            }
            writeJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
        });
    });
    await new Promise((resolve, reject) => {
        server.once("error", reject);
        server.listen(port, host, () => {
            server.off("error", reject);
            resolve();
        });
    });
    const address = server.address();
    if (!address || typeof address === "string")
        throw new Error("Attach server did not return a TCP address");
    const info = createAttachServerInfo(address, host, token, pairingMode);
    options.onListening?.(info);
    return {
        info,
        pairing,
        close: async () => {
            if (closed)
                return;
            closed = true;
            await closeAttachServer(server, clients);
        },
    };
}
async function handleAttachRequest(runtime, token, maxCommandBodyBytes, pairing, pairingMode, clients, request, response) {
    if (!isAuthorized(request, token)) {
        writeJson(response, 401, { error: "Unauthorized" });
        return;
    }
    const url = new URL(request.url ?? "/", "http://127.0.0.1");
    if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/client")) {
        writeHtml(response, 200, renderAttachClientPage());
        return;
    }
    if (request.method === "GET" && url.pathname === "/state") {
        writeJson(response, 200, getAttachState(runtime));
        return;
    }
    if (request.method === "GET" && url.pathname === "/messages") {
        writeJson(response, 200, { messages: runtime.session.messages });
        return;
    }
    if (request.method === "GET" && url.pathname === "/events") {
        openEventStream(runtime, clients, response);
        return;
    }
    if (request.method === "POST" && url.pathname === "/pair/request") {
        const body = parsePairRequest(await readRequestBody(request, maxCommandBodyBytes));
        const client = pairing.request({
            ...body,
            userAgent: request.headers["user-agent"],
            remoteAddress: request.socket.remoteAddress,
        });
        writeJson(response, 200, redactPairingClient(client));
        return;
    }
    if (request.method === "GET" && url.pathname === "/pair/status") {
        const id = url.searchParams.get("id");
        const client = id ? pairing.get(id) : undefined;
        writeJson(response, client ? 200 : 404, client ? redactPairingClient(client) : { error: "Pairing client not found" });
        return;
    }
    if (request.method === "POST" && url.pathname === "/command") {
        const command = parseAttachCommand(await readRequestBody(request, maxCommandBodyBytes));
        if (!isCommandAllowed(request, pairing, pairingMode, commandPermission(command), command.type)) {
            writeJson(response, 403, { error: "Attach client is not approved for this command" });
            return;
        }
        await runAttachCommand(runtime, command);
        writeJson(response, 200, { ok: true });
        return;
    }
    writeJson(response, 404, { error: "Not found" });
}
function openEventStream(runtime, clients, response) {
    response.writeHead(200, {
        "content-type": "text/event-stream",
        "cache-control": "no-cache",
        connection: "keep-alive",
    });
    response.write(`event: state\ndata: ${JSON.stringify(getAttachState(runtime))}\n\n`);
    const client = {
        response,
        unsubscribe: runtime.session.subscribe((event) => writeSseEvent(response, event)),
    };
    clients.add(client);
    response.on("close", () => {
        client.unsubscribe();
        clients.delete(client);
    });
}
function writeSseEvent(response, event) {
    response.write(`event: agent\ndata: ${JSON.stringify(event)}\n\n`);
}
async function runAttachCommand(runtime, command) {
    if (command.type === "get_state" || command.type === "get_messages")
        return;
    if (command.type === "prompt") {
        await runtime.session.prompt(command.message, { source: "rpc" });
        return;
    }
    if (command.type === "steer") {
        await runtime.session.steer(command.message);
        return;
    }
    if (command.type === "follow_up") {
        await runtime.session.followUp(command.message);
        return;
    }
    await runtime.session.abort();
}
function parsePairRequest(body) {
    const value = JSON.parse(body || "{}");
    if (!isRecord(value))
        return {};
    const result = {};
    if (typeof value.name === "string")
        result.name = value.name;
    if (value.permission === "observe" ||
        value.permission === "prompt" ||
        value.permission === "control" ||
        value.permission === "admin") {
        result.permission = value.permission;
    }
    return result;
}
function parseAttachCommand(body) {
    const value = JSON.parse(body || "{}");
    if (!isRecord(value) || typeof value.type !== "string")
        throw new Error("Attach command requires a string type");
    if (value.type === "get_state" || value.type === "get_messages" || value.type === "abort")
        return { type: value.type };
    if (value.type === "prompt" || value.type === "steer" || value.type === "follow_up") {
        if (typeof value.message !== "string" || value.message.trim().length === 0) {
            throw new Error(`${value.type} command requires a non-empty message`);
        }
        return { type: value.type, message: value.message };
    }
    throw new Error(`Unsupported attach command: ${value.type}`);
}
function getAttachState(runtime) {
    const session = runtime.session;
    return {
        sessionId: session.sessionId,
        sessionFile: session.sessionFile,
        sessionName: session.sessionName,
        cwd: session.cwd,
        isStreaming: session.isStreaming,
        isCompacting: session.isCompacting,
        messageCount: session.messages.length,
        pendingMessageCount: session.pendingMessageCount,
        steeringMode: session.steeringMode,
        followUpMode: session.followUpMode,
    };
}
function commandPermission(command) {
    if (command.type === "prompt")
        return "prompt";
    if (command.type === "steer" || command.type === "follow_up" || command.type === "abort")
        return "control";
    return "observe";
}
function isCommandAllowed(request, pairing, pairingMode, permission, commandType) {
    if (pairingMode === "off")
        return true;
    return pairing.checkToken(getAttachClientToken(request), permission, commandType);
}
function getAttachClientToken(request) {
    const header = request.headers["x-threadwell-client-token"];
    if (typeof header === "string")
        return header;
    const url = new URL(request.url ?? "/", "http://127.0.0.1");
    return url.searchParams.get("clientToken") ?? undefined;
}
function redactPairingClient(client) {
    if (!client)
        return {};
    const base = {
        id: client.id,
        code: client.status === "pending" ? client.code : undefined,
        name: client.name,
        permission: client.permission,
        status: client.status,
        createdAt: client.createdAt,
        expiresAt: client.expiresAt,
    };
    if (isApprovedClient(client)) {
        return { ...base, clientToken: client.clientToken };
    }
    return base;
}
function isApprovedClient(client) {
    return "status" in client && client.status === "approved";
}
function isAuthorized(request, token) {
    const header = request.headers.authorization;
    if (header === `Bearer ${token}`)
        return true;
    const url = new URL(request.url ?? "/", "http://127.0.0.1");
    return url.searchParams.get("token") === token;
}
async function readRequestBody(request, maxBytes) {
    const chunks = [];
    let totalBytes = 0;
    for await (const chunk of request) {
        const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
        totalBytes += buffer.byteLength;
        if (totalBytes > maxBytes) {
            throw new AttachRequestTooLargeError(maxBytes);
        }
        chunks.push(buffer);
    }
    return Buffer.concat(chunks).toString("utf8");
}
function writeJson(response, statusCode, value) {
    if (response.headersSent)
        return;
    response.writeHead(statusCode, { "content-type": "application/json" });
    response.end(JSON.stringify(value));
}
function writeHtml(response, statusCode, value) {
    if (response.headersSent)
        return;
    response.writeHead(statusCode, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
    response.end(value);
}
function renderAttachClientPage() {
    return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Threadwell Attach</title>
<style>
:root{color-scheme:dark;background:#101014;color:#f3f3f5;font-family:system-ui,-apple-system,Segoe UI,sans-serif}*{box-sizing:border-box}html,body{height:100%;margin:0}body{min-height:100dvh;display:flex;flex-direction:column;padding:12px;max-width:920px;margin-inline:auto}.topbar{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;padding:4px 2px 8px}h1{font-size:18px;margin:0}.muted{color:#a6a6b0}.card{border:1px solid #30303a;border-radius:16px;padding:14px;margin:6px 0;background:#181820}textarea{box-sizing:border-box;width:100%;min-height:58px;max-height:30dvh;border-radius:12px;border:1px solid #3b3b46;background:#0f0f14;color:#fff;padding:12px;font:inherit;resize:vertical}button{border-radius:12px;border:1px solid #597cff;background:#3b66f5;color:#fff;padding:12px 16px;font:inherit;font-weight:650}button.secondary{background:#242430;border-color:#3b3b46}input,select{display:none}.ok{color:#7ee787}.err{color:#ff7b72}.pairing-screen{flex:1;display:flex;align-items:center;justify-content:center}.pairing-panel{width:min(560px,100%);text-align:center;padding:28px 18px}.pairing-title{font-size:22px;font-weight:750;margin-bottom:8px}.pairing-copy{color:#b7b7c2;line-height:1.45;margin:0 auto 18px;max-width:420px}.pairing-code{font-size:46px;font-weight:800;letter-spacing:.18em;margin:18px 0;color:#fff}.chat-card{flex:1;min-height:0;display:none;flex-direction:column}.chat-header{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:6px}#chat{flex:1;min-height:0;display:flex;flex-direction:column;gap:10px;overflow:auto;padding:4px}.composer{margin-top:8px}.msg{max-width:88%;padding:10px 12px;border-radius:14px;white-space:pre-wrap;line-height:1.35}.msg .role{display:block;font-size:11px;color:#a6a6b0;margin-bottom:4px;text-transform:uppercase;letter-spacing:.04em}.user{align-self:flex-end;background:#244a9b}.assistant{align-self:flex-start;background:#252936}.system{align-self:center;background:#20202a;color:#c8c8d0}body.paired .pairing-screen{display:none}body.paired .chat-card{display:flex}</style>
</head>
<body>
<div class="topbar">
	<div><h1>Threadwell</h1><div class="muted">Remote chat</div></div>
	<div id="status" class="muted">Connecting...</div>
</div>
<main class="pairing-screen" id="pairing-card">
	<section class="card pairing-panel">
		<div class="pairing-title">Pairing and permissions</div>
		<p class="pairing-copy" id="pairing">This phone is not paired yet. Request access, then approve the code in Threadwell to enter the chat.</p>
		<input id="name" placeholder="Device name" value="Mobile"><select id="permission"><option value="prompt">prompt</option></select><button id="pair">Request pairing</button>
	</section>
</main>
<div class="card chat-card">
	<div class="chat-header"><b>Chat</b><button class="secondary" id="refresh">Refresh</button></div>
	<div id="chat"></div>
	<div class="composer"><textarea id="prompt" placeholder="Send a prompt to Threadwell"></textarea><div class="row"><button id="send">Send</button><button class="secondary" id="abort">Abort</button></div></div>
</div>
<script>
const params=new URLSearchParams(location.search);const token=params.get('token')||'';let clientToken='';let pairId='';let refreshTimer=0;const statusEl=document.querySelector('#status');const pairingEl=document.querySelector('#pairing');const pairingCard=document.querySelector('#pairing-card');const chatEl=document.querySelector('#chat');
function debug(line,cls=''){console.debug(cls?cls:'debug',line)}
function headers(extra={}){return {...extra,authorization:'Bearer '+token,...(clientToken?{'x-threadwell-client-token':clientToken}:{})}}
async function json(path,options={}){const r=await fetch(path,{...options,headers:headers(options.headers||{})});if(!r.ok)throw new Error(path+' HTTP '+r.status);return r.json()}
function textFromContent(content){if(typeof content==='string')return content;if(Array.isArray(content))return content.map(p=>typeof p==='string'?p:(p&&typeof p.text==='string'?p.text:'' )).filter(Boolean).join('\\n');return ''}
function roleOf(message){return message.role||message.type||'system'}
function addBubble(role,text){if(!text)return;const div=document.createElement('div');const cls=role==='user'?'user':(role==='assistant'?'assistant':'system');div.className='msg '+cls;const label=document.createElement('span');label.className='role';label.textContent=role;div.appendChild(label);div.appendChild(document.createTextNode(text));chatEl.appendChild(div);chatEl.scrollTop=chatEl.scrollHeight}
function renderMessages(messages){chatEl.innerHTML='';for(const message of messages){const role=roleOf(message);if(role!=='user'&&role!=='assistant')continue;const text=textFromContent(message.content);addBubble(role,text)}if(chatEl.childElementCount===0)addBubble('system','No chat messages yet. Send a prompt from this phone or the PC.')}
async function refreshMessages(){try{const m=await json('/messages');renderMessages(m.messages||[])}catch(e){debug(e.message,'err')}}
function scheduleRefresh(){clearTimeout(refreshTimer);refreshTimer=setTimeout(refreshMessages,350)}
async function loadState(){try{const s=await json('/state');statusEl.innerHTML='<span class="ok">Online</span><br><span class="muted">'+s.cwd+'</span>';await refreshMessages()}catch(e){statusEl.innerHTML='<span class="err">Disconnected</span>'}}
function connectEvents(){const es=new EventSource('/events?token='+encodeURIComponent(token));es.addEventListener('state',e=>{statusEl.innerHTML='<span class="ok">Online</span>';debug('state '+e.data,'muted');scheduleRefresh()});es.addEventListener('agent',e=>{statusEl.innerHTML='<span class="ok">Online</span>';debug('agent '+e.data);scheduleRefresh()});es.onerror=()=>{statusEl.innerHTML='<span class="err">Disconnected</span><br><span class="muted">Host closed or network lost</span>';addBubble('system','Disconnected from Threadwell. Reopen a fresh QR/link after restarting attach.')}}
async function sendCommand(body){const r=await fetch('/command',{method:'POST',headers:headers({'content-type':'application/json'}),body:JSON.stringify(body)});if(!r.ok)throw new Error('/command HTTP '+r.status)}
document.querySelector('#send').onclick=async()=>{const input=document.querySelector('#prompt');const message=input.value.trim();if(!message)return;try{addBubble('user',message);await sendCommand({type:'prompt',message});input.value='';debug('sent prompt','ok');scheduleRefresh()}catch(e){debug(e.message,'err');addBubble('system',e.message)}};
document.querySelector('#abort').onclick=async()=>{try{await sendCommand({type:'abort'});debug('sent abort','ok')}catch(e){debug(e.message,'err')}};
document.querySelector('#refresh').onclick=refreshMessages;
document.querySelector('#pair').onclick=async()=>{try{const p=await json('/pair/request',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:document.querySelector('#name').value,permission:document.querySelector('#permission').value})});pairId=p.id;pairingEl.innerHTML='Pairing code: <b>'+p.code+'</b><br>Approve on TUI with <code>/attach approve '+p.code+'</code><br>Approve on headless host with <code>approve '+p.code+'</code>';pollPair()}catch(e){pairingEl.innerHTML='<span class="err">'+e.message+'</span>'}};
async function pollPair(){if(!pairId)return;try{const p=await json('/pair/status?id='+encodeURIComponent(pairId));if(p.status==='approved'){clientToken=p.clientToken;document.body.classList.add('paired');addBubble('system','Phone approved. Chat mode enabled.');refreshMessages();return}if(p.status==='denied'){pairingEl.innerHTML='<span class="err">Denied by host.</span> You can request pairing again.';pairId='';return}if(p.status==='expired'){pairingEl.innerHTML='<span class="err">Pairing expired.</span> Request a fresh code.';pairId='';return}if(p.status==='revoked'){pairingEl.innerHTML='<span class="err">Pairing revoked.</span> Request pairing again.';pairId='';clientToken='';return}pairingEl.innerHTML='<span class="muted">Approve this code in Threadwell</span><div class="pairing-code">'+p.code+'</div>';setTimeout(pollPair,1500)}catch(e){pairingEl.innerHTML='<span class="err">'+e.message+'</span>'}}
if(!token){statusEl.innerHTML='<span class="err">Missing token in URL</span>'}else{loadState();connectEvents()}
</script>
</body>
</html>`;
}
function createAttachServerInfo(address, configuredHost, token, pairing) {
    const host = configuredHost === "::" ? "[::1]" : configuredHost;
    const url = `http://${host}:${address.port}/state?token=${encodeURIComponent(token)}`;
    return { host: configuredHost, port: address.port, token, url, pairing };
}
async function closeAttachServer(server, clients) {
    for (const client of clients) {
        client.unsubscribe();
        client.response.end();
    }
    clients.clear();
    await new Promise((resolve, reject) => {
        server.close((error) => (error ? reject(error) : resolve()));
    });
}
class AttachRequestTooLargeError extends Error {
    constructor(maxBytes) {
        super(`Attach command body exceeds ${maxBytes} bytes`);
        this.name = "AttachRequestTooLargeError";
    }
}
function isRecord(value) {
    return typeof value === "object" && value !== null;
}
//# sourceMappingURL=attach-server.js.map