/** * Collab live-session wire protocol. * * Hub topology: the host is authoritative, guests never peer. All session * payloads (`CollabFrame`) travel AES-256-GCM sealed; the relay only sees the * plaintext envelope (`[4B uint32 BE peerId][sealed payload]`) plus TEXT JSON * control messages that carry no session data. */ import type { ImageContent } from "@oh-my-pi/pi-ai"; import type { BusChannel, CollabUiRequest, GuestFrame, ParsedCollabLink, Participant, AgentSnapshot as WireAgentSnapshot, } from "@oh-my-pi/pi-wire"; import { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES, ROOM_KEY_BYTES, WRITE_TOKEN_BYTES, } from "@oh-my-pi/pi-wire"; import type { CollabSessionState } from "@oh-my-pi/pi-tui/status-line/types"; import type { AgentSessionEvent } from "../session/agent-session"; import type { SessionEntry, SessionHeader } from "../session/session-entries"; export type { CollabPromptDetails, CollabUiRequest, CollabUiRequestDraft, CollabUiResponseValue, CollabUiSelectItem, ParsedCollabLink, RelayControlMessage, RelayControlToGuest, RelayControlToHost, } from "@oh-my-pi/pi-wire"; export { COLLAB_PROMPT_MESSAGE_TYPE, COLLAB_PROTO, packEnvelope, rewriteEnvelopePeer, unpackEnvelope, } from "@oh-my-pi/pi-wire"; export { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES }; export type CollabParticipant = Participant; export type AgentSnapshot = WireAgentSnapshot; export type { CollabSessionState }; /** * Encrypted payload frames (inside AES-GCM, JSON). The wire package pins the * JSON skeleton (`WireFrame`); host-side frames carry the rich session types * that serialize into those shapes. */ export type CollabFrame = // guest -> host (hello/abort/agent-cmd/fetch-transcript/ui-response are taken verbatim from the wire grammar) | Exclude | { t: "prompt"; text: string; images?: ImageContent[] } // host -> guest | { t: "welcome"; proto: number; header: SessionHeader; state: CollabSessionState; agents: AgentSnapshot[]; /** * Total number of `SessionEntry` items the host will deliver in the * `snapshot-chunk` frames that follow. The guest stays in the * snapshot-loading phase until it has accumulated that many entries * (or a chunk arrives with `final: true`). */ entryCount: number; /** True when this peer joined through a read-only (view) link. */ readOnly?: boolean; } /** * Targeted snapshot fragment delivered after `welcome`. Splits a large * transcript across many small frames so the guest's per-chunk progress * timeout resets each time the relay delivers another batch; without * chunking, a multi-MB session has to fit one giant frame inside the * 30 s first-welcome budget. The last chunk carries `final: true` so the * guest can finalize the replica session. */ | { t: "snapshot-chunk"; entries: SessionEntry[]; final: boolean } | { t: "entry"; entry: SessionEntry } | { t: "event"; event: AgentSessionEvent } | { t: "state"; state: CollabSessionState } /** Mirrored EventBus traffic (task subagent lifecycle/progress channels only). */ | { t: "bus"; channel: BusChannel; data: unknown } /** Full agent-registry snapshot (debounced on registry change). */ | { t: "agents"; agents: AgentSnapshot[] } | { t: "ui-request"; request: CollabUiRequest } | { t: "ui-request-end"; reqId: number } /** Targeted reply to fetch-transcript; `error` marks a terminal read failure that guests must surface without hot retrying. */ | { t: "transcript"; reqId: number; text: string; newSize: number; error?: string } | { t: "bye"; reason: string } | { t: "error"; message: string }; // ═══════════════════════════════════════════════════════════════════════════ // Pre-serialized frames: the host bounds replicated payloads by serializing // them, so the frames that carry them are assembled from that JSON instead of // stringifying the payload a second time. // ═══════════════════════════════════════════════════════════════════════════ declare const encodedFrameBrand: unique symbol; /** A `CollabFrame`'s JSON form, produced only by the encoders below. */ export type EncodedFrame = string & { readonly [encodedFrameBrand]: true }; /** * Field table for a `CollabFrame` variant: its tag under `t`, every other * field mapped to its own name. Annotating an encoder's table with this makes * renaming, adding, or removing a field of that variant a type error here. */ type FrameFields = { readonly [K in keyof Extract]-?: K extends "t" ? T : K; }; const ENTRY_FIELDS: FrameFields<"entry"> = { t: "entry", entry: "entry" }; const EVENT_FIELDS: FrameFields<"event"> = { t: "event", event: "event" }; const SNAPSHOT_CHUNK_FIELDS: FrameFields<"snapshot-chunk"> = { t: "snapshot-chunk", entries: "entries", final: "final", }; const ENTRY_FRAME_HEAD = `{"t":"${ENTRY_FIELDS.t}","${ENTRY_FIELDS.entry}":`; const EVENT_FRAME_HEAD = `{"t":"${EVENT_FIELDS.t}","${EVENT_FIELDS.event}":`; const SNAPSHOT_CHUNK_HEAD = `{"t":"${SNAPSHOT_CHUNK_FIELDS.t}","${SNAPSHOT_CHUNK_FIELDS.entries}":[`; const SNAPSHOT_CHUNK_FINAL = `],"${SNAPSHOT_CHUNK_FIELDS.final}":`; /** The single construction point of {@link EncodedFrame}. */ function encodedFrame(json: string): EncodedFrame { return json as EncodedFrame; } /** `{ t: "entry", entry }` from the entry's JSON. */ export function encodeEntryFrame(entryJson: string): EncodedFrame { return encodedFrame(`${ENTRY_FRAME_HEAD}${entryJson}}`); } /** `{ t: "event", event }` from the event's JSON. */ export function encodeEventFrame(eventJson: string): EncodedFrame { return encodedFrame(`${EVENT_FRAME_HEAD}${eventJson}}`); } /** `{ t: "snapshot-chunk", entries, final }` from each entry's JSON. */ export function encodeSnapshotChunk(entryJsons: readonly string[], final: boolean): EncodedFrame { return encodedFrame(`${SNAPSHOT_CHUNK_HEAD}${entryJsons.join(",")}${SNAPSHOT_CHUNK_FINAL}${final}}`); } // ═══════════════════════════════════════════════════════════════════════════ // Link format: wss:///r/. // ═══════════════════════════════════════════════════════════════════════════ const ROOM_PATH_RE = /^\/r\/([A-Za-z0-9_-]{10,64})(?:\.([A-Za-z0-9_-]+))?$/; const BARE_LINK_RE = /^([A-Za-z0-9_-]{10,64})[#.]([A-Za-z0-9_-]+)$/; const B64URL_RE = /^[A-Za-z0-9_-]+$/; const LOCAL_HOSTNAMES: Record = { localhost: true, "127.0.0.1": true, "::1": true, "[::1]": true }; function isLocalHostname(hostname: string): boolean { return LOCAL_HOSTNAMES[hostname] === true; } export function generateRoomId(): string { const bytes = new Uint8Array(ROOM_ID_BYTES); crypto.getRandomValues(bytes); return Buffer.from(bytes).toString("base64url"); } /** Normalize a relay base URL (ws/wss/http/https) into a ws/wss origin, or an error. */ function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: string } { let url: URL; try { url = new URL(relayUrl); } catch { return { error: `Invalid relay URL: ${relayUrl}` }; } let scheme: string; switch (url.protocol) { case "wss:": case "https:": scheme = "wss:"; break; case "ws:": case "http:": scheme = "ws:"; break; default: return { error: `Unsupported relay URL scheme: ${url.protocol}` }; } if (scheme === "ws:" && !isLocalHostname(url.hostname)) { return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" }; } const port = url.port ? `:${url.port}` : ""; return { origin: `${scheme}//${url.hostname}${port}` }; } /** * Render the shareable link. Compact forms: the default relay collapses to * `.`, other wss relays drop the scheme (`host[:port]/r/…`); * only localhost ws:// links keep their full URL so parsing cannot * mis-infer wss. * * The room secret is dot-joined (`.`) rather than `#`-joined: * RFC 3986 forbids a raw `#` inside a fragment, so strict URL stacks (macOS * Foundation behind terminal click-to-open) percent-encode a second `#` to * `%23` and break the link. Parsers still accept the legacy `#` form and the * mangled `%23` form. * * Full links append the write token to the key * (`base64url(key ∥ writeToken)`); read-only (view) links carry the bare * 32-byte key, which is also the pre-token link format. */ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array): string { const normalized = normalizeRelayOrigin(relayUrl); if ("error" in normalized) throw new Error(normalized.error); const secret = writeToken ? Buffer.concat([key, writeToken]) : Buffer.from(key); const keyText = secret.toString("base64url"); if (normalized.origin === DEFAULT_RELAY_URL) return `${roomId}.${keyText}`; const compact = normalized.origin.startsWith("wss://") ? normalized.origin.slice("wss://".length) : normalized.origin; return `${compact}/r/${roomId}.${keyText}`; } function normalizeCollabWebBaseUrl(relayUrl: string, webUrl?: string): string { const explicitWebUrl = webUrl?.trim(); if (!explicitWebUrl) { const normalized = normalizeRelayOrigin(relayUrl); if ("error" in normalized) throw new Error(normalized.error); return normalized.origin.startsWith("wss://") ? `https://${normalized.origin.slice("wss://".length)}` : `http://${normalized.origin.slice("ws://".length)}`; } let url: URL; try { url = new URL(explicitWebUrl); } catch { throw new Error("collab.webUrl must start with http:// or https://"); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("collab.webUrl must start with http:// or https://"); } if (url.protocol === "http:" && !isLocalHostname(url.hostname)) { throw new Error("collab.webUrl must use https:// unless it targets localhost"); } if (url.search || url.hash) { throw new Error("collab.webUrl must not include a query string or fragment"); } const path = url.pathname.replace(/\/+$/, ""); return `${url.origin}${path}`; } /** * Render the browser deep link. The browser UI may be hosted separately from * the relay; the fragment always carries the relay-specific collab link, so * room secrets stay out of HTTP path and query bytes. */ export function formatCollabWebLink( relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array, webUrl?: string, ): string { return `${normalizeCollabWebBaseUrl(relayUrl, webUrl)}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`; } export function parseCollabLink(link: string): ParsedCollabLink | { error: string } { // Lenient input: terminals that open OSC 8 links through strict URL stacks // (macOS Foundation) percent-encode the legacy second `#` to `%23`. let text = link.trim().replace(/%23/gi, "#"); // Bare `.` (legacy `#`) → default relay. const bare = BARE_LINK_RE.exec(text); if (bare) text = `${DEFAULT_RELAY_URL}/r/${bare[1]}.${bare[2]}`; // Scheme-less `host[:port]/r/…` → wss. else if (!text.includes("://")) text = `wss://${text}`; let url: URL; try { url = new URL(text); } catch { return { error: `Invalid collab link: ${link}` }; } if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) { const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; const parsed = parseCollabLink(inner); if (!("error" in parsed)) return parsed; } const normalized = normalizeRelayOrigin(url.origin); if ("error" in normalized) return normalized; const match = ROOM_PATH_RE.exec(url.pathname); if (!match) { // Non-http(s) deep links may also carry a complete collab link in the // fragment. http(s) links are handled once above so invalid fragments // fall through to direct relay validation instead of double-recursing. const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner); return { error: "Collab link must contain a /r/ path" }; } const roomId = match[1]!; // Key rides dot-joined in the path (`/r/.`); legacy links // carry it in the fragment (`/r/#`). const fragment = match[2] ?? (url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); if (!fragment) { return { error: "Collab link is missing the part" }; } const secret = B64URL_RE.test(fragment) ? new Uint8Array(Buffer.from(fragment, "base64url")) : null; if (!secret || (secret.byteLength !== ROOM_KEY_BYTES && secret.byteLength !== ROOM_KEY_BYTES + WRITE_TOKEN_BYTES)) { return { error: "Collab link key must be 32 (view) or 48 (full) base64url bytes" }; } const key = secret.subarray(0, ROOM_KEY_BYTES); const writeToken = secret.byteLength > ROOM_KEY_BYTES ? secret.subarray(ROOM_KEY_BYTES) : undefined; return { wsUrl: `${normalized.origin}/r/${roomId}`, roomId, key, writeToken }; }