// live.mjs — the session's piece, and the channel that carries it to a phone. // // Every aesel session opens on a new blank piece with a random name. // The piece is a real file in the workspace, so the agent edits it like any // other file, and every save is pushed to Aesthetic Computer's `/run` endpoint // on a private code channel. Anything watching that channel — a phone that // scanned the QR code — hot-reloads the new source immediately. // // This is the path the VS Code extension has always used: POST /run publishes // { piece, source, codeChannel } to Redis, the session server relays it to the // channel's subscribers, and the client swaps the running piece. A viewer joins // the channel by opening `prompt~channel%20~!autorun`, which runs the // prompt's own `channel` command on arrival. // // The `%20` is load-bearing. parse.mjs special-cases anything beginning // `prompt~` and hands the whole remainder to the prompt as ONE parameter, so // the prompt sees the string `channel~` and splits its own arguments // on spaces — a tilde-separated argument arrives glued to the command name and // the channel is silently dropped, leaving the phone on an empty prompt. An // encoded space is what actually reaches `halt` as two tokens. import { PieceRevisions, validatePieceSource } from "./revisions.mjs"; import { EventEmitter } from "node:events"; import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; import { basename, dirname, extname, join, resolve } from "node:path"; import { USER_AGENT } from "./ac-session.mjs"; import { genreFor } from "./genres.mjs"; import { randomChannel, randomSlug } from "./names.mjs"; import { DEFAULT_RUNTIME, runtimeFor, runtimeForExtension } from "./runtimes.mjs"; export const SITE = "https://aesthetic.computer"; // Scannable codes get smaller the shorter the text, and this address exists to // be scanned — see `scanUrl` for the short route it uses. prompt.ac is the same runtime as aesthetic.computer behind a // leaner shell, and nine bytes shorter — which is a whole version of the code // once the scheme a phone camera needs is added back on. export const SCAN_HOST = "prompt.ac"; const DISKS = join("system", "public", "aesthetic.computer", "disks"); // Pieces belong in the disks folder when the workspace is the Aesthetic // Computer repository; anywhere else, the workspace root is the right home. export function pieceDirectory(cwd) { const disks = join(cwd, DISKS); return existsSync(disks) ? disks : cwd; } export class LivePiece extends EventEmitter { constructor({ cwd = process.cwd(), slug = randomSlug(), runtime = DEFAULT_RUNTIME, genre = "piece", channel = randomChannel(), directory = pieceDirectory(cwd), fetch = globalThis.fetch, site = SITE, scanHost = SCAN_HOST, handle = "", token = null, } = {}) { super(); this.cwd = cwd; this.slug = slug; this.genre = genreFor(genre); this.runtime = runtimeFor(this.genre.runtime || runtime); // The channel a signed-out session falls back to. It is unguessable because // nothing about it says whose it is, which is the only thing protecting it: // `/run` will take a push to an opaque channel from any signed-in account. this.fallbackChannel = channel; this.handle = handle; // Resolves an access token, or null when this session cannot push. this.token = token; this.directory = directory; this.fetch = fetch; this.site = site; this.scanHost = scanHost; // The exact blank written for the current file. A file still matching it // has never been touched, which is how an abandoned session knows what it // may delete. Deriving this from the content rather than tracking a flag // keeps the directory watcher — which fires on our own writes — out of it. this.blank = ""; this.watcher = null; this.debounce = null; this.sending = false; this.pushes = 0; // A save is on disk that the channel has not been told about yet. Set when // the watcher arms, cleared when the push it armed lands. this.ahead = false; } get file() { return join(this.directory, `${this.slug}${this.runtime.extension}`); } // The channel this session pushes on. A signed-in session owns // `/`, which `/run` will only accept a push to from that // handle's token — so the name no longer has to hide, and the piece's public // address can be the channel's name as well. get channel() { return this.handle ? `${this.handle}/${this.slug}` : this.fallbackChannel; } // The URL a phone scans. Signed in, that is the piece's own published address: // it exists from the moment the session opens, it is still there after the // session closes, and it is the same address whether you scanned it in the // first second or the last. Signed out there is nothing published and no // channel to own, so it falls back to the opaque `~` route, which // joins the channel and waits for source. // // Short on purpose either way. Every byte here is a module of QR, and the long // form the `~` route expands to — `/prompt~channel%20~!autorun`, a command // line wearing percent-encoding — costs a whole extra version of the symbol. get scanUrl() { return this.handle ? `${this.scanHost}/@${this.handle}/${this.slug}` : `${this.scanHost}/~${this.fallbackChannel}`; } // Where the piece answers once it has been published under a handle. publishedUrl(handle) { return handle ? `${this.site}/@${handle}/${this.slug}` : ""; } // Write the blank piece if nothing is there yet. A blank that is never // edited is removed again on exit, so launching the interface and closing it // leaves no litter in the workspace. create() { mkdirSync(this.directory, { recursive: true }); if (existsSync(this.file)) { this.blank = ""; return this.file; } this.blank = this.genre.blank(this.slug, this.runtime); writeFileSync(this.file, this.blank); this.history.capture(this.blank); return this.file; } // Reserve a fresh file without changing or deleting the previous piece. async fresh() { if (this.sending) throw new Error("Wait for the current live upload."); const next = new LivePiece({cwd:this.cwd, directory:this.directory, runtime:this.runtime.id}); mkdirSync(next.directory, {recursive:true}); for (;;) { next.blank = next.runtime.blank(next.slug); try { writeFileSync(next.file,next.blank,{flag:"wx"}); break; } catch(error) { if(error.code !== "EEXIST") throw error; next.slug=randomSlug(); } } await next.checkpoint(); this.unwatch(); for (const key of ["slug","runtime","blank","fallbackChannel","revision","revisionFile"]) this[key]=next[key]; this.ahead=false;this.pushes=0; this.emit("retarget",this.slug); this.emit("revision",this.revision); return this.file; } // True while the piece is still exactly the blank this session wrote. get pristine() { if (!this.blank) return false; try { return readFileSync(this.file, "utf8") === this.blank; } catch { return false; } } // Drop the blank at `path` if nothing ever edited it. #discard(path, blank) { if (!blank) return false; try { if (readFileSync(path, "utf8") !== blank) return false; rmSync(path, { force: true }); return true; } catch { return false; } } source() { try { return readFileSync(this.file, "utf8"); } catch { return ""; } } // Point at a different piece — the agent wrote or edited something else, or // the user renamed the session's piece. retarget(file) { const path = resolve(this.cwd, file); const runtime = runtimeForExtension(extname(path)); if (!runtime) return false; if (this.genre.runtime && runtime.id !== this.genre.runtime) return false; const slug = basename(path, runtime.extension); if (path === this.file) return false; const previous = this.file; const previousBlank = this.blank; this.directory = dirname(path); this.slug = slug; this.runtime = runtime; this.blank = ""; this.#discard(previous, previousBlank); // The blank was never used. this.emit("retarget", this.slug); return true; } // Rename the session's piece, carrying the blank file along with it. rename(name, runtimeId = this.runtime.id) { const slug = String(name || "").trim(); if (!/^[a-zA-Z0-9_-]+$/.test(slug)) { throw new Error(`piece names may only use letters, digits, "-" and "_"`); } const previous = this.file; const previousBlank = this.blank; const runtime = runtimeFor(runtimeId); if (this.genre.runtime && runtime.id !== this.genre.runtime) { throw new Error(`${this.genre.label} uses javascript`); } this.slug = slug; this.runtime = runtime; this.blank = ""; this.create(); if (previous !== this.file) this.#discard(previous, previousBlank); return this.file; } get history() { return new PieceRevisions(this.file); } async checkpoint(source = this.source()) { const file = this.file; await validatePieceSource(source, file); if (file !== this.file || source !== this.source()) return null; const revision = this.history.capture(source); if (this.revision?.revision !== revision.revision || this.revisionFile !== file) { this.revision = revision; this.revisionFile = file; this.emit("revision", revision); } return revision; } async rollback(version) { // Preserve a complete unobserved edit; a broken edit must still be recoverable. const current = this.source(); let valid = false; try { await validatePieceSource(current, this.file); valid = true; } catch {} if (valid) this.history.capture(current); const revision = await this.history.restore(version); this.revision = revision; this.revisionFile = this.file; this.emit("revision", revision); return revision; } // Push the current source onto the code channel. async push() { // Serialize uploads so a slow older save cannot arrive after a newer one. if (this.pendingPush) { await this.pendingPush.catch(() => {}); return this.push(); } this.sending = true; const pending = this.#push(); this.pendingPush = pending; try { return await pending; } finally { this.pendingPush = null; this.sending = false; } } async #push() { if(this.broadcastEnabled === false)return false; const source = this.source(); if (!source.trim()) return false; const identity = JSON.stringify([this.site, this.channel, this.slug, source]); if (identity === this.lastSentIdentity) { this.ahead = false; return false; } const channel = this.channel, slug = this.slug; if (!await this.checkpoint(source)) return false; { // `/run` takes no anonymous pushes: ownership of a channel is the token, // not the name. A session with no token can still watch its own piece in // a browser, it just cannot put source on anyone else's screen. const headers = { "Content-Type": "application/json", "User-Agent": USER_AGENT, }; const token = await this.token?.(); if (token) headers.Authorization = `Bearer ${token}`; const response = await this.fetch(`${this.site}/run`, { method: "POST", headers, body: JSON.stringify({ piece: slug, source, codeChannel: channel }), }); if (!response.ok) throw new Error(`live push failed (HTTP ${response.status})`); } this.lastSentIdentity = identity; this.pushes += 1; this.ahead = source !== this.source() || channel !== this.channel || slug !== this.slug; this.emit("push", this.pushes, source); return true; } // Watch the whole directory rather than the file: editors and patch tools // replace files instead of writing through them, which leaves a file watch // pointed at a discarded inode. // // One push per save is enough. The session server keeps the last message a // channel received and replays it to whoever joins later, so the piece a // session opens with is still waiting when a phone finally scans the code. // This carried a 4s re-announce heartbeat until that retention landed // (aesthetic-computer d8d3c80053); a beat per session per 4s to cover a // one-line server bug was rent, not architecture. watch(onError = () => {}) { this.unwatch(); let observed = this.source(); try { this.watcher = watch(this.directory, (_event, name) => { if (name && name !== basename(this.file)) return; const saved = this.source(); if (saved === observed) return; observed = saved; clearTimeout(this.debounce); // The save has landed on disk and has not left for the channel yet. // Anything watching the piece — the preview in the corner of the pane // most of all — is showing the version before this one until the push // goes, and would otherwise have no way to know it. if (!this.ahead) { this.ahead = true; this.emit("dirty"); } this.debounce = setTimeout(() => { this.push().catch(onError); }, 80); this.debounce.unref?.(); }); this.watcher.unref?.(); } catch {} return this; } unwatch() { clearTimeout(this.debounce); try { this.watcher?.close(); } catch {} this.watcher = null; } // Remove an untouched blank so an abandoned session leaves nothing behind. cleanup() { this.unwatch(); return this.#discard(this.file, this.blank); } }