Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353// 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<channel>~!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~<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 // `<handle>/<slug>`, 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 `~<channel>` 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<id>~!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); }}