// Browser channel client: `import AyChannel from "agent-yes/channels"`. // // The frontend counterpart to the CLI. It joins the SAME WebRTC mesh as any // `ay ch sync` peer using the isomorphic ChannelPeer (peer.ts) wired to the // browser's native RTCPeerConnection + WebSocket, persists to LocalStorage // (store.browser.ts), and renders a self-contained floating chat window (Shadow // DOM) so an agent and a human can talk on the same page — with no server ever // storing a message. // // const ch = new AyChannel("ay://ch/s.agent-yes.com/#e1."); // await ch.start(); // ch.on("message", render); // ch.mount(); // floating widget, or ch.mount(el) to embed // await ch.send("hello"); import { deriveChannelId, deriveRoom, parseChannelLink, secretFromTopic } from "./link.ts"; import { hlcSend } from "./hlc.ts"; import { makeOp, type Role } from "./op.ts"; import { maxHlc, renderThread, type Message } from "./store.ts"; import { randomHex } from "../../lab/ui/e2e.js"; import { ChannelPeer } from "./peer.ts"; import { LocalStorageStore } from "./store.browser.ts"; export interface AyChannelInfo { /** A channel invite link (ay://ch/… or https://…/w/#ch=…). If given, room/sighost/s are parsed from it. */ link?: string; room?: string; sighost?: string; /** Secret S (64-hex). */ s?: string; name?: string; role?: Role; /** Stable author id; auto-generated + persisted per channel if omitted. */ author?: string; } type Events = "message" | "peers" | "ready"; export class AyChannel { readonly room: string; readonly sighost: string; readonly s: string; channelId = ""; name: string; role: Role; author = ""; private store?: LocalStorageStore; private peer?: ChannelPeer; private listeners = new Map void>>(); private started = false; private peers = 0; /** * Build a channel whose identity is DERIVED from a topic string (e.g. the page * URL) — same topic ⇒ same room, no invite needed. Public to anyone with the * topic (see secretFromTopic). This is what the page bookmarklet uses. */ static async fromTopic( topic: string, opts?: { sighost?: string; name?: string; role?: Role }, ): Promise { const s = await secretFromTopic(topic); const sighost = opts?.sighost ?? "s.agent-yes.com"; const room = await deriveRoom(s); return new AyChannel({ room, sighost, s, name: opts?.name, role: opts?.role }); } constructor(info: string | AyChannelInfo) { const o = typeof info === "string" ? { link: info } : info; const link = o.link ? parseChannelLink(o.link) : null; this.room = o.room ?? link?.room ?? ""; this.sighost = o.sighost ?? link?.sighost ?? "s.agent-yes.com"; this.s = o.s ?? link?.s ?? ""; if (!this.room || !this.s) throw new Error("AyChannel: need a link or {room, s}"); this.name = o.name ?? "guest"; this.role = o.role ?? "human"; if (o.author) this.author = o.author; } /** Derive identity, open the LocalStorage replica, and join the mesh. */ async start(): Promise { if (this.started) return; this.started = true; this.channelId = await deriveChannelId(this.s); this.store = new LocalStorageStore(this.channelId); this.loadIdentity(); this.peer = new ChannelPeer({ room: this.room, sighost: this.sighost, s: this.s, rtc: (globalThis as any).RTCPeerConnection, WebSocketImpl: (globalThis as any).WebSocket, store: this.store, onOp: () => this.emit("message", undefined), onPeers: (n) => { this.peers = n; this.emit("peers", n); }, }); await this.peer.start(); this.emit("ready", undefined); } /** Identity is stable per channel across reloads (a returning tab keeps its author id). */ private loadIdentity(): void { const key = `ay29ch-id:${this.channelId}`; let saved: { author: string; name: string; role: Role } | null = null; try { saved = JSON.parse(localStorage.getItem(key) || "null"); } catch { /* ignore */ } // Author is stable across reloads; name/role fall back to the saved ones only // when the caller left them at their defaults. this.author ||= saved?.author || randomHex(8); if (saved?.name && this.name === "guest") this.name = saved.name; if (saved?.role && this.role === "human") this.role = saved.role; try { localStorage.setItem( key, JSON.stringify({ author: this.author, name: this.name, role: this.role }), ); } catch { /* ignore */ } } /** The rendered thread (folded messages, ordered). */ async messages(): Promise { return renderThread(await (this.store?.all() ?? Promise.resolve([]))); } /** The current confirmed-peer count (presence). */ peerCount(): number { return this.peers; } /** Post a message: persist locally + broadcast to the mesh. */ async send(text: string): Promise { if (!this.store || !this.peer) throw new Error("AyChannel: call start() first"); const t = text.trim(); if (!t) return; const ops = await this.store.all(); const hlc = hlcSend(maxHlc(ops), Date.now(), this.author); const op = makeOp({ author: this.author, name: this.name, role: this.role, hlc, kind: "msg", body: t, }); await this.peer.publish(op); // append + broadcast this.emit("message", undefined); } on(evt: Events, cb: (arg: any) => void): this { (this.listeners.get(evt) ?? this.listeners.set(evt, new Set()).get(evt)!).add(cb); return this; } off(evt: Events, cb: (arg: any) => void): this { this.listeners.get(evt)?.delete(cb); return this; } private emit(evt: Events, arg: any): void { for (const cb of this.listeners.get(evt) ?? []) { try { cb(arg); } catch { /* a listener throwing must not break delivery */ } } } close(): void { this.peer?.close(); this.widget?.remove(); } // --- floating chat widget ------------------------------------------------- // DOM globals (document/HTMLElement) aren't in the Node/Bun type lib this // package compiles against, so DOM access here is intentionally loosely typed; // this method only ever runs in a browser. private widget?: any; // The channel's display name in the header — a local, user-editable label // persisted per room (the secret/identity is unaffected). private labelKey(): string { return `ay29ch-label:${this.room}`; } private defaultLabel(): string { return this.room ? this.room.slice(0, 14) : "Channel"; } private loadLabel(): string { try { return localStorage.getItem(this.labelKey()) || this.defaultLabel(); } catch { return this.defaultLabel(); } } private saveLabel(v: string): void { try { localStorage.setItem(this.labelKey(), v.trim() || this.defaultLabel()); } catch { /* ignore */ } } /** Door icon → prompt for another channel (topic or invite link) and switch to it. */ private async joinAnother(parent: any): Promise { const g = globalThis as any; const input: string | null = g.prompt?.("Join a channel — topic or invite link:", ""); if (!input) return; const isLink = input.startsWith("ay://") || input.includes("#ch="); let next: AyChannel; try { next = isLink ? new AyChannel({ link: input, name: this.name, role: this.role }) : await AyChannel.fromTopic(input, { sighost: this.sighost, name: this.name, role: this.role, }); } catch (e) { g.alert?.("Could not join: " + ((e as any)?.message || e)); return; } this.close(); // tears down this peer + removes this widget await next.start(); next.mount(parent, { open: true }); } /** * Render the floating chat window. With no target it mounts a fixed-position * bubble in the corner; pass an element to embed it there. Auto-calls start(). */ mount(target?: any, opts?: { open?: boolean }): any { if (this.widget) return this.widget; const doc: any = (globalThis as any).document; const host = doc.createElement("div"); host.setAttribute("data-aychannel", this.channelId || this.room); const root = host.attachShadow({ mode: "open" }); root.innerHTML = WIDGET_HTML; this.widget = host; (target ?? doc.body).appendChild(host); const list = root.getElementById("list")!; const input = root.getElementById("input") as any; const form = root.getElementById("form") as any; const badge = root.getElementById("peers")!; const panel = root.getElementById("panel")!; const toggle = root.getElementById("toggle")!; const chname = root.getElementById("chname") as any; const door = root.getElementById("door")!; const parent = target ?? doc.body; chname.value = this.loadLabel(); chname.addEventListener("change", () => this.saveLabel(chname.value)); door.addEventListener("click", () => void this.joinAnother(parent)); const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!); const render = async () => { const msgs = await this.messages(); list.innerHTML = msgs .map((m) => { const mine = m.author === this.author; const time = new Date(m.ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); const body = m.deleted ? "message deleted" : esc(m.text); return `
${esc(m.name)} · ${time}
${body}
`; }) .join(""); list.scrollTop = list.scrollHeight; }; form.addEventListener("submit", (e: any) => { e.preventDefault(); const text = input.value; input.value = ""; void this.send(text); }); toggle.addEventListener("click", () => panel.classList.toggle("open")); if (opts?.open) panel.classList.add("open"); this.on("message", () => void render()); this.on("peers", () => (badge.textContent = String(this.peers))); void (async () => { if (!this.started) await this.start(); await render(); badge.textContent = String(this.peers); })(); return host; } } // Self-contained styles + markup for the Shadow DOM widget (no external assets). const WIDGET_HTML = `
0
`; export default AyChannel; export * from "./index.ts";