diff --git a/aesel/package.json b/aesel/package.json index b3b41c432b..97f6f9e7c1 100644 --- a/aesel/package.json +++ b/aesel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.8.16", + "version": "0.8.17", "private": true, "type": "module", "scripts": { diff --git a/aesel/src/check-piece.mjs b/aesel/src/check-piece.mjs index 9ec7b32271..7429651d00 100644 --- a/aesel/src/check-piece.mjs +++ b/aesel/src/check-piece.mjs @@ -34,10 +34,20 @@ export async function checkPiece(target, { seconds = 4, cwd = process.cwd() } = const address = file ? `${SITE}/${scratch}` : target.startsWith("http") ? target : `${SITE}/${target.replace(/^\/+/, "")}`; const url = `${address}${address.includes("?") ? "&" : "?"}nogap=true&nolabel=true&noauth=true`; + // Puppeteer's own headless shell when it is installed: a fresh profile of + // full Chrome took ~9s to launch and ~13s to close, most of a 40s check, + // and under load booted AC four times slower than the shell did. + let shell = ""; + try { shell = puppeteer.executablePath({ headless: "shell" }); } catch {} const browser = await puppeteer.launch({ - headless: "new", - ...(existsSync(CHROME) ? { executablePath: CHROME } : {}), - args: ["--no-sandbox", "--mute-audio", "--autoplay-policy=no-user-gesture-required"], + ...(shell && existsSync(shell) + ? { headless: "shell", executablePath: shell } + : { headless: "new", ...(existsSync(CHROME) ? { executablePath: CHROME } : {}) }), + args: [ + "--no-sandbox", "--mute-audio", "--autoplay-policy=no-user-gesture-required", + "--no-first-run", "--no-default-browser-check", "--disable-extensions", + "--disable-component-update", "--disable-background-networking", "--disable-sync", + ], timeout: 90000, protocolTimeout: 90000, }); @@ -94,7 +104,11 @@ export async function checkPiece(target, { seconds = 4, cwd = process.cwd() } = await page.keyboard.press("Space").catch(() => {}); await new Promise((r) => setTimeout(r, Math.max(1000, seconds * 1000 - 1200))); } finally { - await browser.close(); + // Nothing is kept from this profile, so there is nothing for a graceful + // close to save; it only made every check wait on Chrome's teardown. + const chrome = browser.process(); + if (chrome) chrome.kill("SIGKILL"); + else await browser.close(); } // A warning whose error was gone before it could be read says nothing the // readable one did not; drop it when a readable one exists. diff --git a/aesel/src/slab-session.mjs b/aesel/src/slab-session.mjs index 83482e6eb8..c0d74f2b7b 100644 --- a/aesel/src/slab-session.mjs +++ b/aesel/src/slab-session.mjs @@ -33,6 +33,17 @@ function summary(text) { return words.length > 7 ? `${value.slice(0, 47)}…` : value; } +// Any way of naming a piece → the bare host+path the rock encodes: +// `notepat`, `/notepat:c`, `@jeffrey/butterfly`, `$cow`, +// `https://aesthetic.computer/@jeffrey/butterfly` → `prompt.ac/…`. +export function previewAddress(target = "") { + const path = String(target || "").trim().split(/\s+/)[0] + .replace(/^https?:\/\//, "") + .replace(/^(www\.)?(aesthetic\.computer|prompt\.ac)(\/|$)/, "") + .replace(/^\/+/, ""); + return path ? `prompt.ac/${path}` : ""; +} + export class SlabSession { constructor({ cwd, @@ -58,6 +69,8 @@ export class SlabSession { this.running = join(this.stateDir, "running-tools", sessionId); this.enabled = false; this.heartbeat = null; + // Set while the rock shows a piece that is not this session's (`preview`). + this.pinned = ""; this.cursorSocket = join(this.stateDir, "cursor.sock"); this.shape = "arrow"; this.cursor = null; @@ -135,14 +148,31 @@ export class SlabSession { // session renames its piece, or switches runtime — so the code on the rock // always points at what is actually running. live(piece = "", scanUrl = "", channel = "") { + this.own = { piece: String(piece || ""), piece_channel: String(channel || ""), scan_url: String(scanUrl || "") }; + if (this.pinned) return; this.#update({ - ...(this.record.scan_url !== String(scanUrl || "") ? {piece_published_at:""} : {}), - piece: String(piece || ""), - piece_channel: String(channel || ""), - scan_url: String(scanUrl || ""), + ...(this.record.scan_url !== this.own.scan_url ? {piece_published_at:""} : {}), + ...this.own, }); } + // Point the rock and its preview at any piece — `notepat`, `@handle/slug`, + // `$code`, a full URL — without it becoming this session's piece. The + // session goes on writing its own; `preview()` with nothing hands the card + // back to it. Returns the address shown, or "" when back on its own. + preview(target = "") { + const address = previewAddress(target); + if (!address) { + if (!this.pinned) return ""; + this.pinned = ""; + this.#update({ ...(this.own || { piece: "", piece_channel: "", scan_url: "" }), piece_published_at: "", flow: this.ownFlow || "live" }); + return ""; + } + this.pinned = address; + this.#update({ piece: "", piece_channel: "", scan_url: address, piece_published_at: "", flow: "live" }); + return address; + } + published() { this.#update({piece_published_at:new Date().toISOString()}); } revision(revision) { @@ -162,6 +192,9 @@ export class SlabSession { // question about which to draw that nobody has to answer if it cannot arise. flow(state = "live") { const clean = ["live", "ahead", "pushing"].includes(state) ? state : "live"; + this.ownFlow = clean; + // Someone else's piece on the card is never ahead of our file. + if (this.pinned) return; if (this.record.flow === clean) return; this.#update({ flow: clean }); } diff --git a/aesel/src/tui.mjs b/aesel/src/tui.mjs index e0d17064b4..a908023bd6 100755 --- a/aesel/src/tui.mjs +++ b/aesel/src/tui.mjs @@ -2368,7 +2368,7 @@ async function submitInput(submittedText, submittedMessages = null) { "notice", pro ? "/ask [on|off] · /provider · /model [name] · /mouse [on|off] · /layout · /inbox · /mode · /backend [id] · /login · /logout · /whoami · /handle [name] · /update · /new · /clear · /close · /quit ctrl-c interrupts a running turn" - : "/about · /medium · /artifacts · /select UUID · /artifact · /export FILE · /sharing · /transcript · /profile · /inbox · /mode · /mouse [on|off] · /performance [frames] · /energy · /latest · /login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /versions · /rollback vN · /runtime [id] · /frame [ocr] · /settings · /backend [id] · /model [name] · /effort · /handle [name] · /update · /open · /qr · /new [thread] · /clear · /quit ctrl-c interrupts a running turn", + : "/about · /medium · /artifacts · /select UUID · /artifact · /export FILE · /sharing · /transcript · /profile · /inbox · /mode · /mouse [on|off] · /performance [frames] · /energy · /latest · /login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /preview [piece] · /versions · /rollback vN · /runtime [id] · /frame [ocr] · /settings · /backend [id] · /model [name] · /effort · /handle [name] · /update · /open · /qr · /new [thread] · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -2441,6 +2441,14 @@ async function submitInput(submittedText, submittedMessages = null) { } if (command === "/settings" || command === "/effort") return openSettings(command === "/effort" ? 2 : 0); if (command === "/model" || command === "/models") return commandModel(rest); + // Look at any piece on the rock's card without making it this session's: + // `/preview notepat`, `/preview @handle/slug`, `/preview $cow`. Bare + // `/preview` hands the card back to the piece being written here. + if (command === "/preview") { + const shown = slabSession.preview(rest); + addEntry("notice", shown ? `Previewing ${shown} · /preview to come back` : "Previewing this session's own piece"); + return redraw(); + } if (command === "/piece") { if (rest) { try { diff --git a/aesel/test/slab-session.test.mjs b/aesel/test/slab-session.test.mjs index 355c838036..ae6194bb2d 100644 --- a/aesel/test/slab-session.test.mjs +++ b/aesel/test/slab-session.test.mjs @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { SlabSession } from "../src/slab-session.mjs"; +import { SlabSession, previewAddress } from "../src/slab-session.mjs"; const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); const exists = async (path) => stat(path).then(() => true, () => false); @@ -127,3 +127,40 @@ test("a pointer with nobody listening stays quiet", async (context) => { session.pointer("arrow"); session.close(); }); + +test("previews any piece without taking it as the session's own", async (context) => { + const root = await mkdtemp(join(tmpdir(), "easel-slab-")); + context.after(() => rm(root, { recursive: true, force: true })); + const session = new SlabSession({ cwd: "/project", pid: process.pid, tty: "ttys099", sessionId: "ac-preview", slabHome: root }); + const active = join(root, "state", "active-prompts", "ac-preview"); + session.start(); + session.live("butterfly.mjs", "prompt.ac/@jeffrey/butterfly", "jeffrey/butterfly"); + session.flow("ahead"); + + assert.equal(session.preview("https://aesthetic.computer/notepat"), "prompt.ac/notepat"); + let marker = await readJson(active); + assert.equal(marker.scan_url, "prompt.ac/notepat"); + assert.equal(marker.piece, ""); + assert.equal(marker.flow, "live"); + + // The session keeps writing its own piece underneath; the card stays put. + session.live("moth.mjs", "prompt.ac/@jeffrey/moth", "jeffrey/moth"); + session.flow("pushing"); + marker = await readJson(active); + assert.equal(marker.scan_url, "prompt.ac/notepat"); + + assert.equal(session.preview(""), ""); + marker = await readJson(active); + assert.equal(marker.scan_url, "prompt.ac/@jeffrey/moth"); + assert.equal(marker.piece, "moth.mjs"); + assert.equal(marker.flow, "pushing"); +}); + +test("names a piece any way it is written", () => { + assert.equal(previewAddress("notepat"), "prompt.ac/notepat"); + assert.equal(previewAddress("/notepat:c"), "prompt.ac/notepat:c"); + assert.equal(previewAddress("@jeffrey/butterfly"), "prompt.ac/@jeffrey/butterfly"); + assert.equal(previewAddress("$cow"), "prompt.ac/$cow"); + assert.equal(previewAddress("prompt.ac/@jeffrey/butterfly"), "prompt.ac/@jeffrey/butterfly"); + assert.equal(previewAddress(" "), ""); +}); diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift index 6b2572bb6e..68ab49bf36 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift @@ -121,9 +121,8 @@ struct ClaudeSession { var artifactPreview: LocalArtifactPreview? /// The piece this session is writing, with its extension — `balozo.mjs`. - /// Only Easel sets it, and it names the rock: a session that is - /// holding a piece should be addressable by that piece's name rather than - /// by a second unrelated word drawn from its session id. + /// Only Easel sets it. It titles the piece's card, not the rock: the + /// rock keeps its session name (see `SigilRenderer.name(for:)`). var piece: String = "" var piecePublishedAt: String = "" var pieceVersion: Int = 0 diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift index 6854eb8f02..9753d5dc0f 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -55,7 +55,6 @@ struct LedgerEntry: Codable, Equatable { var proxNamespace: String? var proxName: String? var proxIdentity: String? - var proxPieceName: String? } struct Ledger: Codable { @@ -416,8 +415,10 @@ final class LedgerStore { loopboyContact: s.loopboyContact.isEmpty ? nil : s.loopboyContact, scanURL: s.scanURL.isEmpty ? nil : s.scanURL) } - // Easel namespace follows the current artifact; provider identity stays - // stable for resuming. Retain collision choices only for this piece. + // The Easel address is the rock's own name, never the piece's, so + // `prox:aesel:` keeps pointing at the same session however many + // pieces it writes or previews. Provider identity stays stable for + // resuming, and a collision choice is kept for as long as the session. let previous = (try? JSONDecoder().decode(Ledger.self, from: Data(contentsOf: URL(fileURLWithPath: Self.localFile))))?.entries ?? [] let peerFiles = (try? FileManager.default.contentsOfDirectory(atPath: Self.peersDir)) ?? [] @@ -432,17 +433,12 @@ final class LedgerStore { for index in easelIndices { let session = sessions[index] let identity = session.providerSessionId.isEmpty ? session.sessionId : session.providerSessionId - let filename = (session.piece as NSString).lastPathComponent - let stem = (filename as NSString).deletingPathExtension - let clean = stem.lowercased().filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." } - let base = clean.isEmpty ? "untitled" : String(clean.prefix(100)) - let prior = previous.first { $0.proxIdentity == identity && $0.proxPieceName == base }?.proxName + let base = entries[index].name + // Only a collision variant of this same name carries over; a name + // left from when the address followed the piece does not. + let prior = previous.first { $0.proxIdentity == identity }?.proxName + .flatMap { $0 == base || $0.hasPrefix(base + "-") ? $0 : nil } var name = prior ?? base - if used.contains(name) { - let owner = String(session.pieceChannel.split(separator: "/").first ?? "") - .lowercased().filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } - name = owner.isEmpty ? base : owner + "/" + base - } if used.contains(name) { let collisionBase = name let suffix = String(format: "%016llx", SigilRenderer.seed(for: identity + "\u{1}" + selfHost)) @@ -460,7 +456,6 @@ final class LedgerStore { entries[index].proxNamespace = "easel" entries[index].proxName = name entries[index].proxIdentity = identity - entries[index].proxPieceName = base } entries.append(contentsOf: advertisedAgents()) diff --git a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift index fa85f4d0d3..283ed71baa 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift @@ -70,17 +70,11 @@ enum SigilRenderer { return routeName } } - // An Easel session is holding one named piece, and that name is - // already the session's public identity: it is the slug in the address on - // the rock and the channel the piece is pushed on. Drawing a second, - // unrelated word from the session id meant the rock and the piece - // disagreed about what the session was called — you would open `balozo` - // and be handed `muzad`. The piece wins, and follows a rename. - if session.agentType == "easel" { - let slug = (session.piece as NSString).deletingPathExtension - let clean = slug.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } - if !clean.isEmpty { return String(clean.prefix(16)) } - } + // An Easel session used to take its piece's name here, so the rock + // renamed itself the moment a piece was published (`bunido` became + // `butterfly`) and again on every swap. A session is not its piece: + // it can write several, and preview any. The rock keeps the one name + // it was born with; the piece is named on its card and its address. return name(forSessionId: session.sessionId) }