/** * `ay ch` — channels: local-first, end-to-end threads where AI agents and humans * talk on a topic. No server ever stores a message; every participant keeps a * full replica as an append-only CRDT log, cwd-scoped like the rest of * agent-yes's per-project state: * * ay ch mk [--sighost H] [--name N] [--role agent|human] [--salt HEX] * ay ch join [--as ] [--name N] [--role R] * ay ch ls [--json] * ay ch rm * ay ch send [--name N] [--role R] * ay ch read [-n N] [--json] * ay ch head [-n N] * ay ch tail [-n N] [-f] * ay ch sync [--quiet] — hold the WebRTC mesh (live send/receive) * ay ch pipe — sync + bridge stdin→send, inbound→stdout * * The read/write verbs are pure-local (they only touch the jsonl replica). Live * delivery is a separate `sync` peer that holds the WebRTC mesh, appends inbound * ops, and broadcasts newly-appeared local ops — so send/tail COMPOSE with a * running sync through the shared replica file, no IPC/daemon. The browser side * (Phase 3) joins the same mesh with its own peer, so agent↔human chat is truly * peer-to-peer with no central bridge. * * The channels core (ts/channels/) is isomorphic and reused verbatim by the * browser lib; this file is the Node CLI shell over it, mirroring ts/ws.ts. */ import { randomBytes } from "crypto"; import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises"; import os from "os"; import path from "path"; import { deriveChannelId, deriveRoom, formatChannelLink, formatChannelWebLink, hlcSend, isChannelLink, makeOp, maxHlc, parseChannelLink, renderThread, secretFromTopic, type Message, type Role, } from "./channels/index.ts"; import { appendOps, channelFilePath, readOps } from "./channels/store.node.ts"; import type { ChannelPeerStore } from "./channels/peer.ts"; import type { Op } from "./channels/index.ts"; const REG_SCHEMA = "ay-ch/v1"; interface ChannelRegEntry { topic: string; channelId: string; room: string; sighost: string; /** Shared secret S (64-hex); the registry file is chmod 0600. */ s: string; /** Stable per-participant id — the HLC node + author identity. */ author: string; name: string; role: Role; createdAt: number; } interface ChannelRegistry { schema: string; channels: Record; } // --- identity defaults ------------------------------------------------------ /** An agent (AGENT_YES_PID set) defaults to role "agent"; a human shell to "human". */ export function defaultRole(): Role { return process.env.AGENT_YES_PID ? "agent" : "human"; } /** Default display name: $AY_CH_NAME, else the OS username, else "anon". */ export function defaultName(): string { if (process.env.AY_CH_NAME) return process.env.AY_CH_NAME; try { return os.userInfo().username || "anon"; } catch { return "anon"; } } function validRole(v: string | boolean | undefined): Role { if (v === "agent" || v === "human") return v; throw new Error(`--role must be "agent" or "human"`); } // --- registry IO ------------------------------------------------------------ function registryPath(cwd: string): string { return path.join(cwd, ".agent-yes", "channels.json"); } export async function readRegistry(cwd: string): Promise { try { const reg = JSON.parse(await readFile(registryPath(cwd), "utf-8")) as ChannelRegistry; if (reg && typeof reg === "object" && reg.channels) return reg; } catch { /* missing/corrupt → empty */ } return { schema: REG_SCHEMA, channels: {} }; } async function writeRegistry(cwd: string, reg: ChannelRegistry): Promise { const file = registryPath(cwd); await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, JSON.stringify(reg, null, 2) + "\n"); await chmod(file, 0o600).catch(() => {}); // best-effort (no-op on Windows) } /** * Resolve a `` operand. A registered topic yields its full entry; a * bare invite link yields just its channelId (read-only — sending needs an * identity, i.e. `ay ch join` first). Anything else is an error. */ export async function resolveChannel( reg: ChannelRegistry, arg: string, ): Promise<{ channelId: string; entry: ChannelRegEntry | null }> { const entry = reg.channels[arg]; if (entry) return { channelId: entry.channelId, entry }; if (isChannelLink(arg)) { const link = parseChannelLink(arg); if (link) return { channelId: await deriveChannelId(link.s), entry: null }; } throw new Error(`no channel "${arg}" — see 'ay ch ls', or 'ay ch join '`); } // --- display ---------------------------------------------------------------- /** One rendered thread line: `HH:MM:SS name(a): text 👍2`. Pure, for tests. */ export function formatMessage(m: Message): string { const t = new Date(m.ms).toISOString().slice(11, 19); const who = `${m.name}(${m.role[0]})`; const text = m.deleted ? "(deleted)" : m.text; const react = m.reactions.length ? " " + m.reactions.map((r) => `${r.emoji}${r.by.length > 1 ? r.by.length : ""}`).join(" ") : ""; return `${t} ${who}: ${text}${react}`; } // --- flag parsing (tiny, mirrors ws.ts) ------------------------------------- function parseFlags( args: string[], known: Record, ): { flags: Record; positional: string[] } { const flags: Record = {}; const positional: string[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]!; if (!a.startsWith("-") || a === "-") { positional.push(a); continue; } // support -n as an alias for --limit const isShortN = a === "-n"; const eq = a.indexOf("="); const name = isShortN ? "limit" : eq === -1 ? a.replace(/^--?/, "") : a.slice(a.startsWith("--") ? 2 : 1, eq); const kind = known[name]; if (!kind) throw new Error(`unknown flag ${a}`); if (kind === "bool") { if (eq !== -1) throw new Error(`--${name} takes no value`); flags[name] = true; } else { const v = eq !== -1 ? a.slice(eq + 1) : args[++i]; if (v === undefined) throw new Error(`${a} requires a value`); flags[name] = v; } } return { flags, positional }; } function limitOf(flags: Record): number | undefined { if (flags.limit === undefined) return undefined; const n = Number(flags.limit); if (!Number.isInteger(n) || n < 0) throw new Error(`-n/--limit must be a non-negative integer`); return n; } // --- verbs ------------------------------------------------------------------ async function cmdChMk(cwd: string, args: string[]): Promise { const { flags, positional } = parseFlags(args, { sighost: "value", name: "value", role: "value", salt: "value", topic: "value", }); const topic = positional[0]; if (!topic || positional.length > 1) throw new Error( "usage: ay ch mk [--topic ] [--sighost H] [--name N] [--role R] [--salt HEX]", ); const reg = await readRegistry(cwd); if (reg.channels[topic]) throw new Error(`channel "${topic}" already exists (ay ch rm ${topic} to replace)`); // --topic derives a deterministic secret from a public string (e.g. a page URL) // so this peer lands in the SAME channel a bookmarklet on that URL joins. const s = typeof flags.topic === "string" ? await secretFromTopic(flags.topic) : typeof flags.salt === "string" ? flags.salt : randomBytes(32).toString("hex"); const [channelId, room] = await Promise.all([deriveChannelId(s), deriveRoom(s)]); const sighost = typeof flags.sighost === "string" ? flags.sighost : undefined; const entry: ChannelRegEntry = { topic, channelId, room, sighost: sighost ?? "s.agent-yes.com", s, author: randomBytes(8).toString("hex"), name: typeof flags.name === "string" ? flags.name : defaultName(), role: flags.role ? validRole(flags.role) : defaultRole(), createdAt: Date.now(), }; reg.channels[topic] = entry; await writeRegistry(cwd, reg); const link = formatChannelLink({ sighost: entry.sighost, room, s }); process.stdout.write( `created channel "${topic}" (${entry.name}, ${entry.role})\n` + ` invite (CLI): ${link}\n` + ` invite (browser): ${formatChannelWebLink({ sighost: entry.sighost, room, s })}\n` + `\n share the invite; others join with: ay ch join '${link}'\n`, ); return 0; } async function cmdChJoin(cwd: string, args: string[]): Promise { const { flags, positional } = parseFlags(args, { as: "value", name: "value", role: "value" }); const linkStr = positional[0]; if (!linkStr || positional.length > 1) throw new Error("usage: ay ch join [--as ] [--name N] [--role R]"); const link = parseChannelLink(linkStr); if (!link) throw new Error(`not a channel invite link: ${linkStr}`); const channelId = await deriveChannelId(link.s); const topic = typeof flags.as === "string" ? flags.as : `ch-${link.room.slice(0, 10)}`; const reg = await readRegistry(cwd); const existing = reg.channels[topic]; if (existing && existing.channelId !== channelId) throw new Error(`topic "${topic}" is already bound to a different channel — pick another --as`); const entry: ChannelRegEntry = existing ?? { topic, channelId, room: link.room, sighost: link.sighost, s: link.s, author: randomBytes(8).toString("hex"), name: typeof flags.name === "string" ? flags.name : defaultName(), role: flags.role ? validRole(flags.role) : defaultRole(), createdAt: Date.now(), }; if (typeof flags.name === "string") entry.name = flags.name; if (flags.role) entry.role = validRole(flags.role); reg.channels[topic] = entry; await writeRegistry(cwd, reg); process.stdout.write(`joined channel "${topic}" (${entry.name}, ${entry.role})\n`); return 0; } async function cmdChLs(cwd: string, args: string[]): Promise { const { flags, positional } = parseFlags(args, { json: "bool" }); if (positional.length) throw new Error("ay ch ls takes no positional args"); const reg = await readRegistry(cwd); const topics = Object.keys(reg.channels).sort(); const rows = await Promise.all( topics.map(async (topic) => { const e = reg.channels[topic]!; const ops = await readOps(cwd, e.channelId); const msgs = renderThread(ops); const last = msgs.length ? msgs[msgs.length - 1]! : null; return { topic, channelId: e.channelId, name: e.name, role: e.role, messages: msgs.length, lastMs: last ? last.ms : null, }; }), ); if (flags.json) { process.stdout.write(JSON.stringify({ schema: REG_SCHEMA, channels: rows }, null, 2) + "\n"); return 0; } if (rows.length === 0) { process.stderr.write(`no channels in ${cwd} — 'ay ch mk ' or 'ay ch join '\n`); return 0; } const w = Math.max(5, ...rows.map((r) => r.topic.length)); process.stdout.write(`${"TOPIC".padEnd(w)} ${"MSGS".padStart(5)} LAST\n`); for (const r of rows) { const last = r.lastMs ? new Date(r.lastMs).toISOString().slice(0, 19).replace("T", " ") : "-"; process.stdout.write(`${r.topic.padEnd(w)} ${String(r.messages).padStart(5)} ${last}\n`); } return 0; } async function cmdChRm(cwd: string, args: string[]): Promise { const { positional } = parseFlags(args, {}); const topic = positional[0]; if (!topic || positional.length > 1) throw new Error("usage: ay ch rm "); const reg = await readRegistry(cwd); const entry = reg.channels[topic]; if (!entry) throw new Error(`no channel "${topic}"`); delete reg.channels[topic]; await writeRegistry(cwd, reg); await rm(channelFilePath(cwd, entry.channelId), { force: true }); process.stdout.write(`removed channel "${topic}" (local replica deleted; peers unaffected)\n`); return 0; } async function cmdChSend(cwd: string, args: string[]): Promise { const { flags, positional } = parseFlags(args, { name: "value", role: "value" }); const topic = positional[0]; const text = positional.slice(1).join(" "); if (!topic || !text) throw new Error("usage: ay ch send "); const reg = await readRegistry(cwd); const { channelId, entry } = await resolveChannel(reg, topic); if (!entry) throw new Error(`join "${topic}" before sending: ay ch join `); const ops = await readOps(cwd, channelId); const hlc = hlcSend(maxHlc(ops), Date.now(), entry.author); const op = makeOp({ author: entry.author, name: typeof flags.name === "string" ? flags.name : entry.name, role: flags.role ? validRole(flags.role) : entry.role, hlc, kind: "msg", body: text, }); await appendOps(cwd, channelId, [op]); // Phase 2: also hand `op` to the serve daemon to broadcast over the mesh. return 0; } async function readAndRender(cwd: string, channelId: string): Promise { return renderThread(await readOps(cwd, channelId)); } async function cmdChRead( cwd: string, args: string[], mode: "read" | "head" | "tail", ): Promise { const { flags, positional } = parseFlags(args, { limit: "value", json: "bool", follow: "bool", f: "bool", }); const topic = positional[0]; if (!topic || positional.length > 1) throw new Error(`usage: ay ch ${mode} [-n N]`); const reg = await readRegistry(cwd); const { channelId } = await resolveChannel(reg, topic); const n = limitOf(flags); let msgs = await readAndRender(cwd, channelId); if (mode === "head") msgs = msgs.slice(0, n ?? 10); else if (mode === "tail") msgs = msgs.slice(-(n ?? 96)); else if (n !== undefined) msgs = msgs.slice(-n); if (flags.json) { process.stdout.write(JSON.stringify(msgs, null, 2) + "\n"); } else { for (const m of msgs) process.stdout.write(formatMessage(m) + "\n"); } if (mode === "tail" && (flags.follow || flags.f)) { await followChannel(cwd, channelId, new Set(msgs.map((m) => m.id))); } return 0; } /** * Follow a channel, printing messages as they appear. Phase 1 polls the local * replica (fs.watch is unreliable in long-lived daemons — see the fswatch-dies * note), so it surfaces same-cwd appends; Phase 2's daemon feeds it live peer * traffic. Runs until SIGINT. */ function followChannel(cwd: string, channelId: string, seen: Set): Promise { return new Promise((resolve) => { const timer = setInterval(async () => { const msgs = await readAndRender(cwd, channelId); for (const m of msgs) { if (seen.has(m.id)) continue; seen.add(m.id); process.stdout.write(formatMessage(m) + "\n"); } }, 500); const stop = () => { clearInterval(timer); resolve(); }; process.once("SIGINT", stop); process.once("SIGTERM", stop); }); } // --- live mesh (Phase 2) ---------------------------------------------------- /** Store adapter the mesh peer drives (persist + dedup, returns new ops). */ function nodeStore(cwd: string, channelId: string): ChannelPeerStore { return { all: () => readOps(cwd, channelId), append: (ops) => appendOps(cwd, channelId, ops), }; } /** One inbound op as a line (only messages are surfaced live). */ function printOp(op: Op): void { if (op.kind !== "msg") return; const t = new Date(Number(op.hlc.split(".")[0])).toISOString().slice(11, 19); process.stdout.write(`${t} ${op.name}(${op.role[0]}): ${op.body ?? ""}\n`); } /** * Hold the WebRTC mesh for a channel: append inbound ops to the local replica and * broadcast newly-appeared LOCAL ops (e.g. from a separate `ay ch send`) to peers. * All coordination is through the jsonl file — no IPC — so `send`/`tail` compose * with a running `sync` unchanged. Resolves when stopped (SIGINT/SIGTERM) or when * `extraStdin` (pipe mode) drives it. Returns the started peer + a stop fn. */ async function runMesh( cwd: string, entry: ChannelRegEntry, opts: { onInbound?: (op: Op) => void; quiet?: boolean }, ): Promise<{ stop: () => void; done: Promise; peer: import("./channels/peer.ts").ChannelPeer; }> { const { ChannelPeer } = await import("./channels/peer.ts"); // Node transport: node-datachannel + Cloudflare TURN, loaded lazily from the // proven share stack so the mesh peer stays isomorphic (the browser injects its // own globals). const { importRTC, getIceServers } = await import("./share.ts"); const rtc = await importRTC(); const seen = new Set((await readOps(cwd, entry.channelId)).map((o) => o.id)); const peer = new ChannelPeer({ room: entry.room, sighost: entry.sighost, s: entry.s, rtc, iceServers: getIceServers, store: nodeStore(cwd, entry.channelId), onOp: (op) => { seen.add(op.id); opts.onInbound?.(op); if (!opts.quiet) printOp(op); }, onPeers: (n) => process.stderr.write(`[ch] peers: ${n}\n`), }); await peer.start(); const poll = setInterval(() => { void (async () => { for (const op of await readOps(cwd, entry.channelId)) { if (seen.has(op.id)) continue; seen.add(op.id); await peer.publish(op); } })(); }, 500); let resolveDone!: () => void; const done = new Promise((r) => (resolveDone = r)); const stop = () => { clearInterval(poll); peer.close(); resolveDone(); }; process.once("SIGINT", stop); process.once("SIGTERM", stop); return { stop, done, peer }; } async function cmdChSync(cwd: string, args: string[]): Promise { const { flags, positional } = parseFlags(args, { quiet: "bool" }); const topic = positional[0]; if (!topic || positional.length > 1) throw new Error("usage: ay ch sync [--quiet]"); const reg = await readRegistry(cwd); const { entry } = await resolveChannel(reg, topic); if (!entry) throw new Error(`join "${topic}" before syncing: ay ch join `); process.stderr.write(`syncing "${topic}" over the mesh — Ctrl-C to stop\n`); const { done } = await runMesh(cwd, entry, { quiet: !!flags.quiet }); await done; return 0; } async function cmdChPipe(cwd: string, args: string[]): Promise { const { positional } = parseFlags(args, {}); const topic = positional[0]; if (!topic || positional.length > 1) throw new Error("usage: ay ch pipe "); const reg = await readRegistry(cwd); const { entry } = await resolveChannel(reg, topic); if (!entry) throw new Error(`join "${topic}" before piping: ay ch join `); const { done } = await runMesh(cwd, entry, {}); // stdin lines → messages const rl = (await import("readline")).createInterface({ input: process.stdin }); rl.on("line", (line) => { const text = line.trimEnd(); if (!text) return; void (async () => { const ops = await readOps(cwd, entry.channelId); const hlc = hlcSend(maxHlc(ops), Date.now(), entry.author); await appendOps(cwd, entry.channelId, [ makeOp({ author: entry.author, name: entry.name, role: entry.role, hlc, kind: "msg", body: text, }), ]); // the mesh poll loop broadcasts it on the next tick })(); }); await done; rl.close(); return 0; } export type EmbedMode = | { kind: "live"; link: string } // the invite (with secret) is baked into the file | { kind: "placeholder" } // page injects window.AY_CH_LINK at runtime (server-rendered) | { kind: "from-url" }; // channel derived from the page URL — NO secret in the file /** * The embed `\n` ); } if (mode.kind === "placeholder") { return ( `\n` + `\n` ); } return ( `\n` + `\n` ); } async function cmdChEmbed(cwd: string, args: string[]): Promise { const { flags, positional } = parseFlags(args, { host: "value", placeholder: "bool", "from-url": "bool", }); const topic = positional[0]; if (!topic || positional.length > 1) throw new Error("usage: ay ch embed [--host H] [--placeholder | --from-url]"); if (flags.placeholder && flags["from-url"]) throw new Error("--placeholder and --from-url are mutually exclusive"); const reg = await readRegistry(cwd); const { entry } = await resolveChannel(reg, topic); if (!entry) throw new Error(`join "${topic}" before embedding: ay ch join `); const host = typeof flags.host === "string" ? flags.host : "agent-yes.com"; const invite = formatChannelLink({ sighost: entry.sighost, room: entry.room, s: entry.s }); const mode: EmbedMode = flags["from-url"] ? { kind: "from-url" } : flags.placeholder ? { kind: "placeholder" } : { kind: "live", link: invite }; // stdout: only the snippet (safe to pipe/paste). stderr: the guidance. process.stdout.write(buildEmbedSnippet(host, topic, mode)); if (mode.kind === "from-url") { process.stderr.write( `\n URL-derived: no secret in the snippet — the channel is computed from the page URL,\n` + ` so a committed/static file carries no token (ideal for static public pages like\n` + ` Cloudflare Pages). Anyone who opens the same URL joins — that IS the membership.\n`, ); } else if (mode.kind === "placeholder") { process.stderr.write( `\n Placeholder: the invite is NOT in the snippet — set window.AY_CH_LINK at runtime\n` + ` (server-rendered / env), so a committed file carries no secret. This needs a\n` + ` runtime; on a STATIC host use --from-url instead. Invite to inject: ${invite}\n`, ); } else { process.stderr.write( `\n ⚠ This snippet embeds a LIVE channel secret (read+write). Anyone who can read the\n` + ` page source can join. Do NOT commit it to a public or deploy-bound file. For a\n` + ` static public page use --from-url (no secret in the file); for a server-rendered\n` + ` page use --placeholder. Prefer a dedicated random-secret channel you can abandon.\n`, ); } process.stderr.write( ` channels.js must load from https://${host}/w/channels.js — if it 404s (not yet on the\n` + ` CDN), self-host the bundle (lab/ui/cf/public/w/channels.js) and pass --host ,\n` + ` pinning a version. The embed is a