diff --git a/aesel/src/desktop-session.mjs b/aesel/src/desktop-session.mjs
index e355023f85..7b413cd65c 100644
--- a/aesel/src/desktop-session.mjs
+++ b/aesel/src/desktop-session.mjs
@@ -8,7 +8,7 @@ const LIMIT = 32 * 1024 * 1024;
export function desktopSnapshot({ cwd, backend, model, effort = "", live, state, options, engine, handoff = "", archivedConversation = [] }) {
return clone({ schema: 1, cwd: resolve(cwd), savedAt: new Date().toISOString(), backend, model, effort,
live: { file: live.file, runtime: live.runtime?.id || live.runtime, channel: live.fallbackChannel || live.channel, genre: live.genre?.id || "piece" },
- ui: Object.fromEntries(["entries", "input", "cursor", "history", "historyIndex", "queued", "medium", "livePaused", "showQr", "autoAllow", "scrollOffset", "pieceSlug", "media", "spend"].map((key) => [key, state[key]]).filter(([, value]) => value !== undefined)),
+ ui: Object.fromEntries(["entries", "input", "cursor", "history", "historyIndex", "queued", "medium", "livePaused", "showQr", "autoAllow", "scrollOffset", "pieceSlug", "media", "mediaRecent", "subject", "spend"].map((key) => [key, state[key]]).filter(([, value]) => value !== undefined)),
options: { autopublish: options.autopublish, mouseEnabled: options.mouseEnabled },
engine: { threadId: engine.threadId || "", ...(Number.isSafeInteger(engine.turns) ? {turns: engine.turns} : {}), ...(["ac", "open"].includes(backend) ? { messages: engine.messages || [], turns: engine.turns || 0 } : {}) }, handoff, archivedConversation });
}
diff --git a/aesel/src/render.mjs b/aesel/src/render.mjs
index aa4bedde8d..49f7b50d78 100644
--- a/aesel/src/render.mjs
+++ b/aesel/src/render.mjs
@@ -830,7 +830,10 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) {
} else {
const input = Array.from(cleanText(state.input || ""));
const cursor = Math.max(0, Math.min(state.cursor ?? input.length, input.length));
- const room = Math.max(1, width - 2);
+ // The file the next message is about, as a chip at the head of the
+ // line: picked from the media list, sent with the message, then gone.
+ const chip = state.subject ? `${state.subject.glyph} ${clipText(state.subject.name, Math.max(8, Math.floor(width / 4)))} › ` : "";
+ const room = Math.max(1, width - 2 - textWidth(chip));
const start = Math.max(0, cursor - room + 1);
const shown = input.slice(start, start + room);
const at = cursor - start;
@@ -839,8 +842,8 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) {
// ground after it, and the bar has to run to the edge.
// No painted block: the terminal's own cursor stands here and blinks
// the way it does everywhere else. The frame says where it goes.
- const lead = `${shape.prompt ? `${shape.prompt} ` : ""}${start > 0 ? "‹" : ""}`;
- inner = `${shape.prompt ? `${ink(palette.prompt)}${shape.prompt}${ink(palette.text)} ` : ""}${start > 0 ? "‹" : ""}${shown.slice(0, at).join("")}${under === " " && at >= shown.length ? "" : under}${shown.slice(at + 1).join("")}`;
+ const lead = `${chip}${shape.prompt ? `${shape.prompt} ` : ""}${start > 0 ? "‹" : ""}`;
+ inner = `${chip ? `${ink(palette.highlight)}${chip}${ink(palette.text)}` : ""}${shape.prompt ? `${ink(palette.prompt)}${shape.prompt}${ink(palette.text)} ` : ""}${start > 0 ? "‹" : ""}${shown.slice(0, at).join("")}${under === " " && at >= shown.length ? "" : under}${shown.slice(at + 1).join("")}`;
state.cursorCell = { row: height - shape.bottom.length + shape.bottom.indexOf("bar") + 1, col: 1 + textWidth(lead) + textWidth(shown.slice(0, at).join("")) };
}
// The words start on the bar's first cell: the purple is the margin.
@@ -996,7 +999,7 @@ export function dropdownGeometry(state, width, height, shape = state.layout || {
// The anchor row is the status line; without one, the bar.
const anchor = height - bottom.length + (statusRow >= 0 ? statusRow : Math.max(0, bottom.lastIndexOf("bar")));
const facts = proStatus(state, width, false, shape).spans;
- const span = facts.find((s) => s.name === "model") || facts.find((s) => s.name === "engine");
+ const span = drop.kind === "media" ? facts.find((s) => s.name === "media") : facts.find((s) => s.name === "model") || facts.find((s) => s.name === "engine");
const items = drop.loading ? [{ id: "", label: "loading…", detail: "" }] : drop.items.length ? drop.items : [{ id: "", label: "nothing to choose", detail: "" }];
const count = Math.max(1, Math.min(items.length, 10, anchor - 2));
const index = Math.max(0, Math.min(drop.index || 0, items.length - 1));
@@ -1276,8 +1279,8 @@ export function proStatus(state, width, useColor, shape = state.layout || {}) {
x += textWidth(separator);
}
spans.push({ name, x, width: textWidth(text) });
- // The model underlines under the mouse: it is the one fact that is a control.
- const hovered = (name === "model" && state.hover === "model") || (name === "engine" && state.hover === "provider");
+ // The controls underline under the mouse: the model, and the media file.
+ const hovered = (name === "model" && state.hover === "model") || (name === "engine" && state.hover === "provider") || (name === "media" && state.hover === "media");
// While the machine works the handle breathes: bright, then muted, on the
// dance clock — a pulse you can see from across the room.
let fact;
@@ -1357,8 +1360,8 @@ export function headerAction(state, columns, rows, x, y) {
if (banner && y - 1 >= banner.top && y - 1 < banner.top + banner.height && x - 1 >= banner.x && x - 1 < banner.x + banner.inner) return "update";
const row = shape.bottom.lastIndexOf("status");
if (row < 0 || y !== rows - (shape.bottom.length - 1 - row)) return "";
- const hit = proStatus(state, Math.max(32, columns), false, shape).spans.find((span) => (span.name === "model" || span.name === "engine") && x >= span.x + 1 && x <= span.x + span.width);
- return hit ? (hit.name === "engine" ? "provider" : "model") : "";
+ const hit = proStatus(state, Math.max(32, columns), false, shape).spans.find((span) => ["model", "engine", "media"].includes(span.name) && x >= span.x + 1 && x <= span.x + span.width);
+ return hit ? (hit.name === "engine" ? "provider" : hit.name) : "";
}
if(y===rows-2){const hit=modelControls(state,columns).find(c=>x>=c.x&&x {};
function pinMedia(media) {
unwatchMedia();
state.media = { ...media, source: "pinned", version: (state.media?.version || 0) + 1 };
+ rememberMedia(media);
const show = () => {
if (profile.private) return;
const { kind, path, mime, name, version } = state.media;
@@ -1619,6 +1622,65 @@ function pinMedia(media) {
redraw();
});
}
+// The files this session's card has shown, newest first, for the list the
+// media name opens on the status line.
+function rememberMedia(media) {
+ const recent = (state.mediaRecent || []).filter((path) => path !== media.path);
+ state.mediaRecent = [media.path, ...recent].slice(0, 8);
+}
+// Click the media name: the files the card has shown, to put one back on it
+// and keep it there, then what can be done with the one showing now.
+function openMediaDropdown() {
+ const current = state.media?.path;
+ const paths = [...new Set([current, ...(state.mediaRecent || [])].filter(Boolean))];
+ const files = paths.map((file) => mediaFile(file, cwd)).filter(Boolean);
+ const items = files.map((media) => ({
+ file: media.path,
+ label: `${media.path === current ? "● " : " "}${media.glyph} ${media.name}`,
+ detail: fishPath(path.dirname(media.path)),
+ }));
+ if (state.subject) items.push({ act: "unsubject", label: " ✕ not about it", detail: `the next message drops ${state.subject.name}` });
+ if (current && mediaFile(current, cwd)) {
+ if (state.media.source === "pinned") items.push({ act: "unpin", label: " ⊘ unpin", detail: "the card follows the tools again" });
+ else items.push({ act: "pin", file: current, label: " ⦿ pin", detail: "hold it on the card, reload on every write" });
+ items.push(
+ { act: "open", label: " ↗ open", detail: "in its own app" },
+ { act: "reveal", label: " ⌕ reveal", detail: "in Finder" },
+ { act: "copy", label: " ⧉ copy path", detail: "" },
+ );
+ }
+ state.dropdown = { kind: "media", title: "media", items, index: Math.max(0, items.findIndex((item) => item.file === current && !item.act)), loading: false };
+ redraw();
+}
+function chooseMedia(item) {
+ state.dropdown = null;
+ const current = state.media?.path;
+ if (item.file && (!item.act || item.act === "pin")) {
+ const media = mediaFile(item.file, cwd);
+ if (!media) { addEntry("error", `${path.basename(item.file)} is gone`); return redraw(); }
+ slabSession.preview("");
+ pinMedia(media);
+ // Picking a file is also saying what the next message is about, so the
+ // message can be short: "louder in the lift".
+ if (!item.act) state.subject = { path: media.path, name: media.name, glyph: media.glyph, mime: media.mime };
+ flash(item.act ? `${media.name} pinned · reloads on every write` : `next message is about ${media.name} · backspace drops it`);
+ } else if (item.act === "unsubject") {
+ state.subject = null;
+ } else if (item.act === "unpin") {
+ unpinMedia();
+ flash("unpinned");
+ } else if (current && (item.act === "open" || item.act === "reveal")) {
+ const child = spawn("open", item.act === "reveal" ? ["-R", current] : [current], { stdio: "ignore", detached: true });
+ child.on("error", (error) => { addEntry("error", errorText(error)); redraw(); });
+ child.unref();
+ } else if (current && item.act === "copy") {
+ const child = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
+ child.on("error", (error) => { addEntry("error", errorText(error)); redraw(); });
+ child.stdin.end(current);
+ flash("path copied");
+ }
+ return redraw();
+}
function unpinMedia() {
unwatchMedia();
unwatchMedia = () => {};
@@ -2828,8 +2890,13 @@ async function startTurn(text, { from = "", recovery = false } = {}) {
state.queued = [];
return finish();
}
+ // A typed message carries the file it is about; a retry carries it again.
+ const about = recovery ? recoveryRequest?.about : !from ? state.subject : null;
if (!from && !recovery) {
- transcript.event("user", { text });
+ state.subject = null;
+ if (about) flash(`sent about ${about.name}`);
+ if (recoveryRequest) recoveryRequest.about = about;
+ transcript.event("user", { text, ...(about ? { about: about.path } : {}) });
// The first thing asked is what the session was about.
if (!subject) {
subject = text.slice(0, 140);
@@ -2857,7 +2924,7 @@ async function startTurn(text, { from = "", recovery = false } = {}) {
state.activityStage='';redraw();
}
if(recoveryRequest)recoveryRequest.submitted=true;
- await engine.startTurn(text+runtimeFeedbackContext(observed)+pixels.context,{images:pixels.images});
+ await engine.startTurn(text+aboutContext(about)+runtimeFeedbackContext(observed)+pixels.context,{images:pixels.images});
} catch (error) {
state.busy = false;
state.status = "failed";
@@ -2871,6 +2938,11 @@ async function startTurn(text, { from = "", recovery = false } = {}) {
}
}
+// What a chip on the prompt adds to the message: which file "this" is.
+const aboutContext = (about) => about
+ ? `\n\n[This message is about ${about.path} (${about.mime}), the file picked from the media list and showing on the Slab card.]`
+ : "";
+
function replaceInput(value) {
state.input = value;
state.cursor = Array.from(value).length;
@@ -3069,7 +3141,8 @@ function handleKey(input) {
} else if (input === "\x1b[D") state.cursor = Math.max(0, state.cursor - 1);
else if (input === "\x1b[C") state.cursor = Math.min(Array.from(state.input).length, state.cursor + 1);
else if (input === "\x7f" || input === "\b") {
- if (state.cursor > 0) {
+ if (state.cursor === 0 && state.subject) state.subject = null;
+ else if (state.cursor > 0) {
const characters = Array.from(state.input);
characters.splice(--state.cursor, 1);
state.input = characters.join("");
@@ -3154,6 +3227,7 @@ function decodeKeys(buffer) {
if (mouse.click && action === "profile") openProfile();
if (mouse.click && action === "update") { void submitInput("/update"); return; }
if (mouse.click && (action === "model" || action === "provider")) { if (pro) openDropdown(); else openSettings(); }
+ if (mouse.click && action === "media") { if (state.dropdown?.kind === "media") closeDropdown(); else openMediaDropdown(); }
if (mouse.click && action === "dismiss") closeDropdown();
if (action.startsWith("pick:") && state.dropdown) { const index = Number(action.slice(5)); if (mouse.click) void chooseDropdown(index); else if (state.dropdown.index !== index) { state.dropdown.index = index; redraw(); } }
if(mouse.click&&action.startsWith('settings:')){
diff --git a/aesel/test/render.test.mjs b/aesel/test/render.test.mjs
index 2aa7c5f3d7..dd3b02c1af 100644
--- a/aesel/test/render.test.mjs
+++ b/aesel/test/render.test.mjs
@@ -481,6 +481,35 @@ test("a drop-down stands on the fact that opened it, and a click on one of its r
assert.match(loading, /loading…/);
});
+test("the media name is a control: a click opens its list, standing on the name", async () => {
+ const { dropdownGeometry, headerAction, proStatus } = await import("../src/render.mjs");
+ const base = {
+ workspace: "/client", mode: "remote", status: "ready", busy: false, input: "",
+ account: "@tester", model: "claude-sonnet-5", providerSettings: { backend: "claude", model: "claude-sonnet-5" },
+ profile: { name: "pro" }, entries: [{ id: "u", kind: "user", text: "hi" }],
+ media: { glyph: "🔊", name: "climbalift-now.mp3", path: "/w/out/climbalift-now.mp3" },
+ };
+ const span = proStatus(base, 100, false).spans.find((s) => s.name === "media");
+ assert.ok(span, "the media file is on the line");
+ const row = 24 - 2 + 2; // the status line, one-based
+ assert.equal(headerAction(base, 100, 24, span.x + 2, row), "media", "clicking the name opens its list");
+ const items = [
+ { file: "/w/out/climbalift-now.mp3", label: "● 🔊 climbalift-now.mp3", detail: "/w/out" },
+ { act: "open", label: " ↗ open", detail: "in its own app" },
+ ];
+ const state = { ...base, hover: "media", dropdown: { kind: "media", title: "media", items, index: 0, loading: false } };
+ const g = dropdownGeometry(state, 100, 24);
+ assert.equal(g.x, proStatus(state, 100, false).spans.find((s) => s.name === "media").x, "it stands on the media name");
+ const frame = renderFrame(state, 100, 24, false).split("\n");
+ assert.match(frame[g.top], /▾ media/);
+ assert.match(frame[g.top + 1], /climbalift-now\.mp3 {2,}\/w\/out/);
+ assert.equal(headerAction(state, 100, 24, g.x + 2, g.top + 3), "pick:1");
+ const typed = { ...base, input: "louder in the lift", cursor: 18, subject: { glyph: "🔊", name: "climbalift-now.mp3" } };
+ const bar = renderFrame(typed, 100, 24, false).split("\n")[24 - 3];
+ assert.match(bar, /^🔊 climbalift-now\.mp3 › louder in the lift/, "the file the message is about leads the line");
+ assert.equal(typed.cursorCell.col, 1 + "🔊 climbalift-now.mp3 › ".length + 18, "the cursor sits after the chip and the words");
+});
+
test("a reply's markdown is read, not shown, and pro's page is flush left", async () => {
const { markdown } = await import("../src/render.mjs");
const read = markdown("## Plan\n- **Airtable** needs `auth` first\n- see [docs](https://example.com/x)");
diff --git a/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift
index c846c742af..7885765e5c 100644
--- a/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift
+++ b/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift
@@ -104,10 +104,13 @@ struct LocalArtifactPreview: Equatable {
}
/// No project content is executable. Only the fixed readiness script runs.
- func html(text: String? = nil, waveform: String? = nil, nonce: String) -> String {
+ func html(text: String? = nil, waveform: String? = nil, sketch: SoundSketch? = nil, nonce: String) -> String {
let content: String
let ready: String
- if kind == "picture" {
+ if kind == "sound", let sketch {
+ content = Self.soundContent
+ ready = Self.soundScript(sketch)
+ } else if kind == "picture" {
content = ""
ready = "const a=document.getElementById('artifact'); a.onload=ready; a.onerror=failed; if(a.complete&&a.naturalWidth)ready();"
} else if kind == "sound" {
@@ -135,6 +138,68 @@ struct LocalArtifactPreview: Equatable {
"""
}
+ /// The sound card is the record, not a player: its whole spectrogram,
+ /// lows at the bottom, lit where it has played and dim ahead, with the
+ /// playhead blooming the moment's bands. No button, no transport bar —
+ /// click plays or pauses, drag scrubs, space toggles.
+ static let soundContent = """
+
+
+
+ """
+
+ static func soundScript(_ s: SoundSketch) -> String {
+ """
+ const a=document.getElementById('artifact'),deck=document.getElementById('deck'),c=document.getElementById('c'),g=c.getContext('2d');
+ const q=t=>Array.from(t,ch=>parseInt(ch,36)/35),SP=q('\(s.spectrum)'),PK=q('\(s.peaks)'),B=\(SoundSketch.bands),F=\(SoundSketch.fps),D=\(s.duration),FR=SP.length/B;
+ // Contrast: the quiet two-fifths of the range go to black, the rest curves up.
+ const lv=v=>Math.pow(Math.max(0,(v-.4)/.6),1.5);
+ const col=(k,v,lit)=>`hsl(${330-k*(150/(B-1))},${lit?90:14}%,${lit?4+62*v:5+44*v}%)`;
+ let lit,dim,raf=0,down=null,moved=false;
+ const fmt=t=>{t=Math.max(0,Math.floor(t));return Math.floor(t/60)+':'+String(t%60).padStart(2,'0');};
+ // The spectrogram, drawn once per size: each pixel column the loudest
+ // frame it covers, each band a row, quieter cells darker.
+ function paint(W,H,on){
+ const o=document.createElement('canvas');o.width=W;o.height=H;const x=o.getContext('2d'),bh=H/B;
+ for(let px=0;px0||!a.paused){
+ const fi=Math.min(FR-1,Math.floor(t*F)),w=Math.max(3,W*.012);
+ for(let k=0;k0||!a.paused?fmt(t)+' / ':'')+fmt(D),tw=g.measureText(label).width;
+ g.fillRect(W-tw-fs*1.1,fs*.4,tw+fs*.8,fs*1.3);g.fillStyle=a.paused?'rgba(255,255,255,.75)':'#f27cad';g.fillText(label,W-fs*.7,fs*.55);
+ }
+ function size(){const r=c.getBoundingClientRect(),d=window.devicePixelRatio||1,W=Math.max(1,Math.round(r.width*d)),H=Math.max(1,Math.round(r.height*d));
+ if(W===c.width&&H===c.height&&lit)return draw();c.width=W;c.height=H;lit=paint(W,H,true);dim=paint(W,H,false);draw();}
+ function loop(){draw();raf=a.paused?0:requestAnimationFrame(loop);}
+ const toggle=()=>{a.paused?a.play():a.pause();};
+ const seek=e=>{const r=deck.getBoundingClientRect();a.currentTime=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width))*D;draw();};
+ deck.addEventListener('pointerdown',e=>{down=e.clientX;moved=false;deck.setPointerCapture(e.pointerId);});
+ deck.addEventListener('pointermove',e=>{if(down===null)return;if(Math.abs(e.clientX-down)>4)moved=true;if(moved)seek(e);});
+ deck.addEventListener('pointerup',e=>{if(down!==null&&!moved)toggle();down=null;});
+ document.addEventListener('keydown',e=>{if(e.code==='Space'){e.preventDefault();toggle();}});
+ a.addEventListener('play',()=>{if(!raf)loop();});
+ a.addEventListener('pause',draw);a.addEventListener('ended',draw);a.addEventListener('seeked',draw);
+ new ResizeObserver(size).observe(deck);
+ a.onloadedmetadata=()=>{ready();size();};a.onerror=failed;if(a.readyState>=1){ready();size();}
+ """
+ }
+
/// Static waveform of the actual Easel PCM WAV; unsupported formats simply
/// retain their native audio controls. Never draw invented progress.
static func waveform(_ data: Data) -> String? {
diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift
index 5f28523a63..4d823d76cd 100644
--- a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift
+++ b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift
@@ -425,8 +425,34 @@ final class PromptPreview {
/// Stage only the nominated output. HTML is generated here, never loaded
/// from an authored project, and the WebKit read grant covers this copy only.
+ /// The latest sound's measurements, by artifact key; nil inside means the
+ /// file could not be read and the card falls back to plain controls.
+ private var sketches: [String: SoundSketch?] = [:]
+ private var sketching: String?
+
func loadArtifact(_ artifact: LocalArtifactPreview) {
guard artifact.key != loadedURL else { return }
+ // A sound is measured before its page is written (decode and FFT, about
+ // a second for a whole song in a debug build), off the main thread.
+ // The card says it is loading meanwhile and comes back here when done.
+ if artifact.kind == "sound", sketches[artifact.key] == nil {
+ requestedArtifact = artifact
+ artifactFailed = false
+ artifactLoading = true
+ guard sketching != artifact.key else { return }
+ sketching = artifact.key
+ let url = URL(fileURLWithPath: artifact.path)
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ let sketch = SoundSketch(url: url)
+ DispatchQueue.main.async {
+ guard let self else { return }
+ if self.sketching == artifact.key { self.sketching = nil }
+ self.sketches = [artifact.key: sketch]
+ if self.requestedArtifact == artifact { self.loadArtifact(artifact) }
+ }
+ }
+ return
+ }
loadedURL = artifact.key
requestedArtifact = artifact
artifactFailed = false
@@ -461,8 +487,9 @@ final class PromptPreview {
try bytes.write(to: directory.appendingPathComponent("artifact"), options: .atomic)
}
target = directory.appendingPathComponent("index.html")
- let waveform = artifact.kind == "sound" ? LocalArtifactPreview.waveform(bytes) : nil
- try artifact.html(text: text, waveform: waveform, nonce: nonce).write(to: target, atomically: true, encoding: .utf8)
+ let sketch = artifact.kind == "sound" ? (sketches[artifact.key] ?? nil) : nil
+ let waveform = artifact.kind == "sound" && sketch == nil ? LocalArtifactPreview.waveform(bytes) : nil
+ try artifact.html(text: text, waveform: waveform, sketch: sketch, nonce: nonce).write(to: target, atomically: true, encoding: .utf8)
}
let start = { [weak self] in
guard let self, self.requestedArtifact == artifact else {
@@ -502,7 +529,8 @@ final class PromptPreview {
artifactFailed = false
cover.isHidden = true
cover.image = nil
- state.version = artifact.version
+ state.version = 0
+ state.working = false
state.piece = artifact.kind.capitalized
state.previewError = false
celebrateRefresh()
@@ -519,9 +547,13 @@ final class PromptPreview {
func setState(_ next: PromptPreviewState) {
var resolved = next
if requestedArtifact != nil {
- resolved.version = readyArtifact?.version ?? 0
+ // A file on the card is not the turn's work in progress: the agent
+ // being busy changes nothing about an mp3 already playing, and the
+ // version is only how many times it was written. The chip speaks
+ // for the file alone — while it loads, or when it fails.
+ resolved.version = 0
resolved.piece = (readyArtifact ?? requestedArtifact)?.kind.capitalized ?? next.piece
- resolved.working = next.working || artifactLoading
+ resolved.working = artifactLoading
resolved.previewError = artifactFailed
}
state = resolved
diff --git a/slab/menubar-swift/Sources/SlabMenubar/SoundSketch.swift b/slab/menubar-swift/Sources/SlabMenubar/SoundSketch.swift
new file mode 100644
index 0000000000..12fd511dec
--- /dev/null
+++ b/slab/menubar-swift/Sources/SlabMenubar/SoundSketch.swift
@@ -0,0 +1,103 @@
+import Foundation
+import AVFoundation
+import Accelerate
+
+/// What the sound card draws, measured from the file itself: an overview of
+/// its loudness in columns, and the spectrum in sixteen bands twenty times a
+/// second, so the bars that move while it plays are the record's own and
+/// never an animation standing in for it. Any format AVFoundation reads
+/// (mp3, wav, aiff, m4a). Values are quantized to one base-36 character each,
+/// which keeps a three-minute song's sketch near sixty kilobytes of page.
+struct SoundSketch {
+ static let columns = 480
+ static let bands = 16
+ static let fps = 20
+
+ let duration: Double
+ /// `columns` characters of peak level, then `columns` of RMS.
+ let peaks: String
+ let rms: String
+ /// `frames × bands` characters, frame-major.
+ let spectrum: String
+ let frames: Int
+
+ private static let digits = Array("0123456789abcdefghijklmnopqrstuvwxyz")
+ private static func q(_ x: Float) -> Character { digits[max(0, min(35, Int((x * 35).rounded())))] }
+
+ init?(url: URL, maxSeconds: Double = 20 * 60) {
+ guard let file = try? AVAudioFile(forReading: url) else { return nil }
+ let sr = file.processingFormat.sampleRate, total = Int(file.length)
+ guard sr > 0, total > 0, Double(total) / sr <= maxSeconds else { return nil }
+ // One mono line: the channels averaged, read in blocks.
+ var mono = [Float](repeating: 0, count: total)
+ let block: AVAudioFrameCount = 1 << 16
+ guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: block) else { return nil }
+ var at = 0
+ while at < total {
+ do { try file.read(into: buffer, frameCount: min(block, AVAudioFrameCount(total - at))) } catch { break }
+ let n = Int(buffer.frameLength), ch = Int(buffer.format.channelCount)
+ guard n > 0, let data = buffer.floatChannelData else { break }
+ for c in 0.. 0 else { return nil }
+ duration = Double(count) / sr
+
+ // The overview: peak and RMS per column, against the loudest column.
+ var peakCol = [Float](repeating: 0, count: Self.columns), rmsCol = peakCol
+ mono.withUnsafeBufferPointer { p in
+ for x in 0..= 0 && s < count ? mono[s] * window[j] : 0 }
+ re.withUnsafeMutableBufferPointer { r in im.withUnsafeMutableBufferPointer { m in
+ var split = DSPSplitComplex(realp: r.baseAddress!, imagp: m.baseAddress!)
+ frame.withUnsafeBufferPointer { f in
+ f.baseAddress!.withMemoryRebound(to: DSPComplex.self, capacity: n / 2) { vDSP_ctoz($0, 2, &split, 1, vDSP_Length(n / 2)) }
+ }
+ vDSP_fft_zrip(setup, &split, 1, log2n, FFTDirection(FFT_FORWARD))
+ vDSP_zvmags(&split, 1, &mag, 1, vDSP_Length(n / 2))
+ } }
+ mag.withUnsafeBufferPointer { m in
+ for k in 0../dev/null 2>&1 || return 1
+ hash="$(security find-certificate -Z -c "Slab Menubar Self-Signed" "${KC}" 2>/dev/null | awk '/SHA-1 hash:/{print $3; exit}')"
+ [[ -n "${hash}" ]] || return 1
+ security unlock-keychain -p "${SLAB_SIGN_KC_PASS:-slab-signing}" "${KC}" 2>/dev/null || true
+ # A fresh inode, not an overwrite: kickstart against the old inode's cached
+ # signature is the "Code Signature Invalid" launch failure install.sh
+ # documents. Then sign the bundle around it.
+ cp "${bin}" "${APP_BIN}.next"
+ mv -f "${APP_BIN}.next" "${APP_BIN}"
+ codesign --force --sign "${hash}" --identifier computer.slab.menubar "${APP}" >/dev/null 2>&1 || return 1
+ old="$(pgrep -f "SlabMenubar.app/Contents/MacOS/slab-menubar" | head -1)"
+ launchctl kickstart -k "${LABEL}" || return 1
+ for i in $(seq 1 16); do
+ pid="$(pgrep -f "SlabMenubar.app/Contents/MacOS/slab-menubar" | head -1)"
+ [[ -n "${pid}" && "${pid}" != "${old}" ]] && return 0
+ sleep 0.25
+ done
+ return 1
+}
+
+# The debug binary, without asking SwiftPM (a second package load, ~1.3 s):
+# the newest of the two layouts SwiftPM uses, the ask only as a fallback.
+debug_bin() {
+ local a="${DIR}/.build/out/Products/Debug/slab-menubar-swift" b="${DIR}/.build/debug/slab-menubar-swift"
+ if [[ -x "$a" && ( ! -x "$b" || "$a" -nt "$b" ) ]]; then echo "$a"
+ elif [[ -x "$b" ]]; then echo "$b"
+ else echo "$(cd "${DIR}" && /usr/bin/swift build -c debug --show-bin-path)/slab-menubar-swift"; fi
+}
+
+# 0 relaunched · 1 failed · 75 the host's performance guard deferred the build
once() {
- local t0=$SECONDS bin
+ local t0=$SECONDS bin status sum
# build-dev.sh and install.sh each take the host build lock, so they run
# one after the other, never nested.
- if ! "${DIR}/build-dev.sh" >"${LOG}" 2>&1; then
+ "${DIR}/build-dev.sh" >"${LOG}" 2>&1; status=$?
+ if (( status == 75 )); then
+ printf '… deferred: %s\n' "$(grep -m1 -o 'deferred.*' "${LOG}" | cut -c1-110)"
+ return 75
+ elif (( status != 0 )); then
grep -E 'error:' "${LOG}" | sort -u | head -20
printf '✗ build failed (%ss) · full log %s\n' "$((SECONDS - t0))" "${LOG}"
return 1
fi
- bin="$(cd "${DIR}" && swift build -c debug --show-bin-path)/slab-menubar-swift"
+ bin="$(debug_bin)"
+ # The same binary as last time (a save that compiled to nothing new): no
+ # relaunch, so the menubar does not blink for a comment.
+ sum="$(shasum -a 256 "${bin}" | cut -c1-64)"
+ if [[ -f "${DIR}/.build/.dev-swapped" && "$(cat "${DIR}/.build/.dev-swapped")" == "${sum}" ]] \
+ && pgrep -f "SlabMenubar.app/Contents/MacOS/slab-menubar" >/dev/null; then
+ printf '= unchanged binary, still running (%ss)\n' "$((SECONDS - t0))"
+ return 0
+ fi
+ if swap "${bin}"; then
+ echo "${sum}" > "${DIR}/.build/.dev-swapped"
+ printf '✓ slab relaunched on the debug build (%ss)\n' "$((SECONDS - t0))"
+ return 0
+ fi
+ echo "• full install (first run, bundle changed, or the swap did not come up)"
if ! SLAB_PREBUILT="${bin}" "${DIR}/install.sh" >"${LOG}.install" 2>&1; then
tail -8 "${LOG}.install"
printf '✗ install failed (%ss)\n' "$((SECONDS - t0))"
@@ -27,6 +90,7 @@ once() {
fi
grep -E 'identical|signed with|running \(pid|recovered|! ' "${LOG}.install" | tail -3
if pgrep -f "SlabMenubar.app/Contents/MacOS/slab-menubar" >/dev/null; then
+ echo "${sum}" > "${DIR}/.build/.dev-swapped"
printf '✓ slab relaunched on the debug build (%ss)\n' "$((SECONDS - t0))"
else
printf '✗ slab is not running after install (%ss) · /tmp/slab-menubar.err\n' "$((SECONDS - t0))"
@@ -34,11 +98,22 @@ once() {
fi
}
+# Until it is not deferred: the guard admits the build once load falls.
+until_built() {
+ local status
+ while :; do
+ once; status=$?
+ (( status == 75 )) || return "${status}"
+ sleep 5
+ done
+}
+
if [[ "${1:-}" != "--watch" ]]; then once; exit $?; fi
+# Watch mode waits out the performance guard instead of giving up.
command -v fswatch >/dev/null || { echo "--watch needs fswatch: brew install fswatch"; exit 1; }
-once
+until_built
echo "• watching ${DIR}/Sources (ctrl-c to stop)"
# -o: one line per batch of events; -l: let an editor's burst of writes settle
fswatch -o -l 0.4 -e '.*' -i '\.swift$' "${DIR}/Sources" | while read -r _; do
- echo "• change"; once
+ echo "• change"; until_built
done
--
2.51.2
From 1a9b7ceaa376b16f3c86227cdc9eff707ed44439 Mon Sep 17 00:00:00 2001
From: "prompt.ac/@jeffrey"
Date: Mon, 5 Oct 2026 20:30:22 -0700
Subject: [PATCH 02/20] Post any file to mime.ac as your @handle
`ac mime ["caption"]` posts a file as an opening post on mime.ac,
signed with ~/.ac-token, on its MIME type's board, and prints the thread
address. Aesel's media list offers the same, confirmed before it posts.
The client checks size and type first; files over 8 MiB are refused until
opening posts can carry a storage URL.
Co-Authored-By: Claude Opus 5.5
---
aesel/src/cli.mjs | 16 ++++++++++
aesel/src/mime.mjs | 61 +++++++++++++++++++++++++++++++++++++
aesel/src/terminal-cli.mjs | 2 +-
aesel/src/terminal-help.txt | 1 +
aesel/src/tui.mjs | 30 +++++++++++++++++-
aesel/test/mime.test.mjs | 45 +++++++++++++++++++++++++++
system/backend/MIME.md | 11 +++++++
7 files changed, 164 insertions(+), 2 deletions(-)
create mode 100644 aesel/src/mime.mjs
create mode 100644 aesel/test/mime.test.mjs
diff --git a/aesel/src/cli.mjs b/aesel/src/cli.mjs
index e74e00c33a..410763e27e 100644
--- a/aesel/src/cli.mjs
+++ b/aesel/src/cli.mjs
@@ -6,6 +6,7 @@ import { ACSession } from "./ac-session.mjs";
import { planPublish, publishPiece } from "./publish.mjs";
import { handleColorPlan } from "./handle-colors.mjs";
import { SITE } from "./ac-session.mjs";
+import { planMime, postMime } from "./mime.mjs";
const [command = "", ...rest] = process.argv.slice(2);
const session = new ACSession();
@@ -52,6 +53,21 @@ try {
out(result.route);
if (!result.verified) note("published, but the live file did not read back yet");
}
+ } else if (command === "mime") {
+ // A file you made, posted to mime.ac as an opening post under your
+ // @handle; its MIME type picks the board. Anything after the file is the
+ // caption. Prints the thread's address.
+ const [file, ...words] = rest.filter((argument) => !argument.startsWith("--"));
+ if (!file) fail('usage: ac mime ["caption"]');
+ const plan = planMime(file, { caption: words.join(" ") });
+ if (process.env.AESEL_DRY_RUN === "1") {
+ out(`would post ${plan.name} (${plan.type}, ${(plan.size / 1024).toFixed(0)} KB) to mime.ac as @${session.handle || "handle"}${plan.caption ? ` · "${plan.caption}"` : ""}`);
+ } else {
+ if (!session.handle) fail("sign in first: ac login");
+ note(`posting ${plan.name} as @${session.handle}…`);
+ const posted = await postMime(plan, { session });
+ out(posted.url);
+ }
} else if (command === "check") {
// Run a piece for real (headless Chrome) and print its errors: a local
// file before publishing, or a published @handle/slug or URL after.
diff --git a/aesel/src/mime.mjs b/aesel/src/mime.mjs
new file mode 100644
index 0000000000..27a7b36a3e
--- /dev/null
+++ b/aesel/src/mime.mjs
@@ -0,0 +1,61 @@
+// mime.mjs — post a file you made to mime.ac, as your @handle.
+//
+// mime.ac is AC's discussion layer over media (system/backend/MIME.md). Its
+// API already takes a native upload as an opening post: `POST /api/mime` with
+// `{ parent: null, text, file: { name, type, data } }`, base64 bytes, 8 MiB at
+// most. A bearer token makes the post the signed-in account's; the server
+// resolves the handle from it, never from anything sent here. The board is
+// the file's MIME type. This is the client half, shared by `ac mime` and
+// Aesel's media list.
+import { readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import { SITE } from "./ac-session.mjs";
+
+export const MIME_HOME = "https://mime.ac";
+export const MAX_BYTES = 8 * 1024 * 1024;
+const MAX_CAPTION = 4000;
+
+// The type to declare; the server re-checks it and falls back on the extension.
+const TYPES = {
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
+ mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac", m4a: "audio/mp4", mid: "audio/midi", midi: "audio/midi",
+ mp4: "video/mp4", m4v: "video/mp4", mov: "video/quicktime", webm: "video/webm",
+ pdf: "application/pdf", zip: "application/zip", json: "application/json", wasm: "application/wasm",
+ txt: "text/plain", md: "text/markdown", html: "text/html", css: "text/css",
+ js: "text/javascript", mjs: "text/javascript", lisp: "text/x-lisp", lua: "text/x-lua",
+ ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2",
+};
+export const mimeType = (file) => TYPES[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
+
+// What would be posted, checked before any bytes leave: a real file, not
+// empty, under the limit. Throws with a sentence a person can act on.
+export function planMime(file, { cwd = process.cwd(), caption = "" } = {}) {
+ const absolute = path.resolve(cwd, String(file || ""));
+ let info;
+ try { info = statSync(absolute); } catch { throw new Error(`no such file: ${file}`); }
+ if (!info.isFile()) throw new Error(`not a file: ${file}`);
+ if (info.size === 0) throw new Error(`${path.basename(absolute)} is empty`);
+ if (info.size > MAX_BYTES) {
+ throw new Error(`${path.basename(absolute)} is ${(info.size / 1048576).toFixed(1)} MB; mime.ac takes up to 8 MB (try an mp3 or a smaller export)`);
+ }
+ return { path: absolute, name: path.basename(absolute), type: mimeType(absolute), size: info.size, caption: String(caption || "").trim().slice(0, MAX_CAPTION) };
+}
+
+export const threadUrl = (code) => `${MIME_HOME}/#/t/${code}`;
+
+// Post it. `session` is an ACSession: its token signs the post, so the
+// server credits the account behind it. Returns the thread's code, board
+// and address.
+export async function postMime(plan, { session, fetchImpl = fetch, site = SITE } = {}) {
+ if (!session?.signedIn) throw new Error("sign in first: ac login");
+ const token = await session.token();
+ const data = readFileSync(plan.path).toString("base64");
+ const response = await fetchImpl(`${site}/api/mime`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ parent: null, text: plan.caption, file: { name: plan.name, type: plan.type, data } }),
+ });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok || !body.code) throw new Error(body.error || body.message || `mime.ac answered ${response.status}`);
+ return { code: body.code, board: body.board, url: threadUrl(body.code) };
+}
diff --git a/aesel/src/terminal-cli.mjs b/aesel/src/terminal-cli.mjs
index 9ce814c4f3..67d4a3c9d4 100644
--- a/aesel/src/terminal-cli.mjs
+++ b/aesel/src/terminal-cli.mjs
@@ -38,7 +38,7 @@ if(['--help','-h','help'].includes(args[0])){
out(`account: ${session.label()}`);
}else if(args[0]==='history'){
await run('history-cli.mjs',args.slice(1));
-}else if(['login','logout','whoami','publish','colors','profile','mood','handle','check'].includes(args[0])){
+}else if(['login','logout','whoami','publish','colors','profile','mood','handle','check','mime'].includes(args[0])){
await run('cli.mjs',args);
}else{
const {BACKENDS,BACKEND_ALIASES}=await import('./backends.mjs');
diff --git a/aesel/src/terminal-help.txt b/aesel/src/terminal-help.txt
index d88461ba3f..35c1c5cf05 100644
--- a/aesel/src/terminal-help.txt
+++ b/aesel/src/terminal-help.txt
@@ -9,6 +9,7 @@ Usage: ac [directory] [--runtime mjs|lisp|processing]
aesthetic login | logout | whoami
aesthetic publish [slug]
aesthetic colors
+ aesthetic mime ["caption"]
Open the aesel terminal interface in a workspace, or manage the
shared Aesthetic Computer sign-in (~/.ac-token) and publish a piece under
diff --git a/aesel/src/tui.mjs b/aesel/src/tui.mjs
index e7c1630d3e..24aa6c3729 100755
--- a/aesel/src/tui.mjs
+++ b/aesel/src/tui.mjs
@@ -69,6 +69,7 @@ import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs";
import { SlabSession } from "./slab-session.mjs";
import { mediaPaths, mediaFile, watchMedia, itemText, mediaChanged } from "./media.mjs";
import { watchSource } from "./source-watch.mjs";
+import { planMime, postMime, MAX_BYTES } from "./mime.mjs";
import { Artifacts, MEDIA } from './artifacts.mjs';
import { desktopSnapshot, readDesktopSession, writeDesktopSession, restoreDesktopEngine, writeDesktopControl, readDesktopIntent } from "./desktop-session.mjs";
import { archiveThread, replaceWork } from './new-work.mjs';
@@ -1643,6 +1644,8 @@ function openMediaDropdown() {
if (current && mediaFile(current, cwd)) {
if (state.media.source === "pinned") items.push({ act: "unpin", label: " ⊘ unpin", detail: "the card follows the tools again" });
else items.push({ act: "pin", file: current, label: " ⦿ pin", detail: "hold it on the card, reload on every write" });
+ // Public, so it asks once more before anything leaves the machine.
+ if (session.handle && (state.media.size || 0) <= MAX_BYTES) items.push({ act: "mime", label: " ⇪ post to mime.ac", detail: `public, as @${session.handle}` });
items.push(
{ act: "open", label: " ↗ open", detail: "in its own app" },
{ act: "reveal", label: " ⌕ reveal", detail: "in Finder" },
@@ -1653,8 +1656,17 @@ function openMediaDropdown() {
redraw();
}
function chooseMedia(item) {
- state.dropdown = null;
const current = state.media?.path;
+ if (item.act === "mime" && current) {
+ state.dropdown = { kind: "media", title: "post to mime.ac?", index: 0, loading: false, items: [
+ { act: "mime-yes", file: current, label: ` ⇪ yes, post ${path.basename(current)}`, detail: `public on mime.ac as @${session.handle}` },
+ { act: "cancel", label: " cancel", detail: "" },
+ ] };
+ return redraw();
+ }
+ state.dropdown = null;
+ if (item.act === "mime-yes") { void postToMime(item.file); return redraw(); }
+ if (item.act === "cancel") return redraw();
if (item.file && (!item.act || item.act === "pin")) {
const media = mediaFile(item.file, cwd);
if (!media) { addEntry("error", `${path.basename(item.file)} is gone`); return redraw(); }
@@ -1681,6 +1693,22 @@ function chooseMedia(item) {
}
return redraw();
}
+// The confirmed post: the file as an opening post on mime.ac, the thread's
+// address in the transcript, where it can be opened or copied.
+async function postToMime(file) {
+ let plan;
+ try { plan = planMime(file); } catch (error) { addEntry("error", errorText(error)); return redraw(); }
+ flash(`posting ${plan.name} to mime.ac…`, 8000);
+ redraw();
+ try {
+ const posted = await postMime(plan, { session });
+ addEntry("notice", `Posted ${plan.name} to mime.ac as @${session.handle} · ${posted.board}\n${posted.url}`);
+ transcript.event("mime", { path: plan.path, code: posted.code, board: posted.board });
+ } catch (error) {
+ addEntry("error", `mime.ac: ${errorText(error)}`);
+ }
+ redraw();
+}
function unpinMedia() {
unwatchMedia();
unwatchMedia = () => {};
diff --git a/aesel/test/mime.test.mjs b/aesel/test/mime.test.mjs
new file mode 100644
index 0000000000..8b13f0dec1
--- /dev/null
+++ b/aesel/test/mime.test.mjs
@@ -0,0 +1,45 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, writeFileSync, truncateSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { planMime, postMime, mimeType, threadUrl, MAX_BYTES } from "../src/mime.mjs";
+
+const dir = mkdtempSync(path.join(tmpdir(), "aesel-mime-"));
+const mp3 = path.join(dir, "climb.mp3");
+writeFileSync(mp3, Buffer.from("ID3 sound"));
+
+test("a plan names the file, its type and the caption, and refuses what the server would", () => {
+ const plan = planMime("climb.mp3", { cwd: dir, caption: " lift two " });
+ assert.deepEqual({ ...plan, path: path.basename(plan.path) }, { path: "climb.mp3", name: "climb.mp3", type: "audio/mpeg", size: 9, caption: "lift two" });
+ assert.equal(mimeType("x.FLAC"), "audio/flac");
+ assert.equal(mimeType("x.unknown"), "application/octet-stream");
+ writeFileSync(path.join(dir, "empty.png"), "");
+ assert.throws(() => planMime("empty.png", { cwd: dir }), /empty/);
+ assert.throws(() => planMime("missing.wav", { cwd: dir }), /no such file/);
+ assert.throws(() => planMime(".", { cwd: dir }), /not a file/);
+ const big = path.join(dir, "big.wav");
+ writeFileSync(big, "");
+ truncateSync(big, MAX_BYTES + 1);
+ assert.throws(() => planMime(big), /8\.0 MB; mime\.ac takes up to 8 MB/);
+});
+
+test("a post is an opening post signed by the session's token, and answers with its thread", async () => {
+ const sent = [];
+ const fetchImpl = async (url, init) => { sent.push({ url, init }); return { ok: true, status: 200, json: async () => ({ code: "abc", board: "audio/mpeg", parent: null }) }; };
+ const session = { signedIn: true, token: async () => "tok" };
+ const posted = await postMime(planMime(mp3, { caption: "hi" }), { session, fetchImpl, site: "https://ac.test" });
+ assert.deepEqual(posted, { code: "abc", board: "audio/mpeg", url: "https://mime.ac/#/t/abc" });
+ assert.equal(sent[0].url, "https://ac.test/api/mime");
+ assert.equal(sent[0].init.headers.Authorization, "Bearer tok");
+ const body = JSON.parse(sent[0].init.body);
+ assert.deepEqual({ ...body, file: { ...body.file, data: Buffer.from(body.file.data, "base64").toString() } },
+ { parent: null, text: "hi", file: { name: "climb.mp3", type: "audio/mpeg", data: "ID3 sound" } });
+ assert.equal(threadUrl("x"), "https://mime.ac/#/t/x");
+});
+
+test("signed out or refused, it says why", async () => {
+ await assert.rejects(postMime(planMime(mp3), { session: { signedIn: false } }), /ac login/);
+ const fetchImpl = async () => ({ ok: false, status: 400, json: async () => ({ error: "file over 8 MB" }) });
+ await assert.rejects(postMime(planMime(mp3), { session: { signedIn: true, token: async () => "t" }, fetchImpl }), /file over 8 MB/);
+});
diff --git a/system/backend/MIME.md b/system/backend/MIME.md
index 8eb0f5d9ab..4a9abf5463 100644
--- a/system/backend/MIME.md
+++ b/system/backend/MIME.md
@@ -91,6 +91,17 @@ Legacy piece records without an extension retain the existing JavaScript
default. Both upload paths now preserve the extension for future records.
Rate limiting and moderation tools for anonymous replies remain follow-up work.
+## Posting from a terminal
+
+`ac mime ["caption"]` posts a file as an opening post under the
+signed-in @handle (`~/.ac-token`), on the board of its MIME type, and prints
+`https://mime.ac/#/t/`. `AESEL_DRY_RUN=1` shows what would be posted.
+In Aesel, the media name on the status line opens a list with
+"post to mime.ac", which asks once more before posting. The client is
+`aesel/src/mime.mjs`; it checks size and type before any bytes leave. Files
+over 8 MiB are refused until opening posts can carry a storage URL rather
+than inline bytes.
+
## Validation
Use a disposable loopback MongoDB; the tests refuse remote hosts and create
--
2.51.2
From 1326b0060a436442cea16cdf6ed2bfde4ec7d4eb Mon Sep 17 00:00:00 2001
From: "prompt.ac/@jeffrey"
Date: Mon, 5 Oct 2026 20:47:15 -0700
Subject: [PATCH 03/20] Save consent-bound Oskiewar fighters to AC accounts
---
system/backend/account-deletion.mjs | 3 +
system/netlify/functions/oskiewar-consent.mjs | 10 +-
.../netlify/functions/oskiewar-generation.mjs | 76 +++++++++++++--
system/tests/oskiewar-consent.test.mjs | 28 +++++-
system/tests/oskiewar-generation.test.mjs | 43 ++++++++-
system/tests/oskiewar-turnstile-live.mjs | 95 +++++++++++++++++++
system/tests/oskiewar-turnstile.browser.mjs | 74 +++++++++++++++
xbox/OSKIEWAR-RELEASE.md | 23 +++++
xbox/live/oskiewar-wizard.mjs | 86 ++++++++++++-----
9 files changed, 401 insertions(+), 37 deletions(-)
create mode 100644 system/tests/oskiewar-turnstile-live.mjs
create mode 100644 system/tests/oskiewar-turnstile.browser.mjs
diff --git a/system/backend/account-deletion.mjs b/system/backend/account-deletion.mjs
index 9e18e2f284..4edda484e4 100644
--- a/system/backend/account-deletion.mjs
+++ b/system/backend/account-deletion.mjs
@@ -141,6 +141,7 @@ const EXPORT = [
// tokens, signed payloads or mint capabilities. An allowlist also keeps
// future provider credentials out of this download.
const PRIVATE_EXPORT = [
+ ["oskiewar-fighters", "owner", ["fighter", "acceptedAt", "expiresAt"]],
["whistlegraph-speech-requests", "user", ["_id", "braincells", "charged", "free", "paid", "status", "startedAt", "finishedAt"]],
["whistlegraph-iap-accounts", "_id", ["createdAt"]],
["whistlegraph-iap-purchases", "user", [
@@ -360,6 +361,8 @@ const DELETE = [
["nom-scores", (sub) => ({ user: sub })],
["cancelok", (sub) => ({ user: sub })],
["oskiewar-maps", (sub) => ({ owner: sub })],
+ ["oskiewar-fighters", (sub) => ({ owner: sub })],
+ ["oskiewar-generation-jobs", (sub) => ({ owner: sub })],
["easel-image-jobs", (sub) => ({ _id: { $regex: `^${escape(sub)}:` } })],
["easel-image-budget", (sub) => ({ _id: { $regex: `^${escape(sub)}:` } })],
["ai-usage", (sub, ctx) => ({ handle: { $in: ctx.handles } })],
diff --git a/system/netlify/functions/oskiewar-consent.mjs b/system/netlify/functions/oskiewar-consent.mjs
index 24b71c1722..1a8886ea62 100644
--- a/system/netlify/functions/oskiewar-consent.mjs
+++ b/system/netlify/functions/oskiewar-consent.mjs
@@ -146,6 +146,8 @@ export async function handler(event) {
const { scope, error } = readScope(body);
if (error) return fail(400, error);
+ if (body.requestId !== undefined && !/^[a-zA-Z0-9-]{16,64}$/.test(body.requestId))
+ return fail(400, "Invalid consent request ID.");
const gateway = process.env.REGARDE_GATEWAY_URL;
const salt = process.env.REGARDE_SUBJECT_SALT;
@@ -160,11 +162,11 @@ export async function handler(event) {
subject: pseudonym(user.sub, salt),
operation_type: "DATA_OPERATION",
frozen_fields: frozenFields(scope),
- // Idempotent per subject per exact scope: asking the same question twice
- // returns the same receipt instead of littering the chain with duplicates,
- // while any change to the scope is a genuinely new ask.
+ // One explicit choice may retry safely. A new choice gets a new requestId,
+ // so consent after withdrawal cannot revive the retired receipt.
+ // Older clients retain their subject/scope idempotency.
idempotency_key: createHash("sha256")
- .update(JSON.stringify([pseudonym(user.sub, salt), frozenFields(scope)]))
+ .update(JSON.stringify([pseudonym(user.sub, salt), frozenFields(scope), ...(body.requestId ? [body.requestId] : [])]))
.digest("hex").slice(0, 32),
operation_descriptor: {
purpose: PURPOSE,
diff --git a/system/netlify/functions/oskiewar-generation.mjs b/system/netlify/functions/oskiewar-generation.mjs
index 44819ad609..d56a528b6c 100644
--- a/system/netlify/functions/oskiewar-generation.mjs
+++ b/system/netlify/functions/oskiewar-generation.mjs
@@ -4,6 +4,44 @@ import { connect } from '../../backend/database.mjs';
import { pseudonym, frozenFields } from './oskiewar-consent.mjs';
import { gateRoutes, verifyGenerationCapability, readGrantedPhoto, generateAppearance, RECIPE } from '../../backend/oskiewar-generation.mjs';
const respond = (statusCode, body) => ({ statusCode, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, body: JSON.stringify(body) });
+const SAVED_MS = 24 * 60 * 60 * 1000;
+
+// AC owns the account binding. Handles are labels and may change; neither
+// handles nor Auth0 subjects are sent to REGARDE or the appearance model.
+async function fighterAccount(userSub) {
+ const { db } = await connect();
+ const profile = await db.collection('@handles').findOne({ _id: userSub });
+ const handle = profile?.handle ? '@' + profile.handle.replace(/^@/, '') : null;
+ const fighters = db.collection('oskiewar-fighters');
+ await fighters.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
+ return { db, handle, fighters };
+}
+
+async function restoreFighter(account, subject, routes, token) {
+ const saved = await account.fighters.findOne({ _id: subject });
+ if (!saved) return respond(200, { handle: account.handle, status: 'empty' });
+ const remove = async () => {
+ await account.fighters.deleteOne({ _id: subject, 'fighter.hash': saved.fighter.hash });
+ return respond(200, { handle: account.handle, status: 'empty' });
+ };
+ if (saved.expiresAt.getTime() <= Date.now()) return remove();
+ const resumed = await fetch(routes.media.replace(/media$/, 'resume'), {
+ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ subject, receipt: saved.fighter.receipt }), signal: AbortSignal.timeout(10000),
+ });
+ if ([403, 404].includes(resumed.status)) return remove();
+ if (!resumed.ok) throw Object.assign(new Error('REGARDE could not check your saved fighter.'), { status: 503 });
+ const renewal = await resumed.json();
+ const keys = await fetch(routes.jwks, { signal: AbortSignal.timeout(10000) });
+ if (!keys.ok) throw Object.assign(new Error('REGARDE keys are unavailable.'), { status: 503 });
+ const capability = renewal.capability?.jws;
+ const grant = verifyGenerationCapability(capability, await keys.json(), subject);
+ if (grant.receipt !== saved.fighter.receipt || grant.scope_hash !== saved.fighter.scopeHash) return remove();
+ await readGrantedPhoto({ routes, token, capability, subject, hash: saved.fighter.sourceHash });
+ return respond(200, { handle: account.handle, status: 'accepted', fighter: saved.fighter,
+ acceptedAt: saved.acceptedAt, savedUntil: saved.expiresAt,
+ validUntil: Math.min(grant.exp * 1000, saved.expiresAt.getTime()) });
+}
export async function handler(event) {
if (event.httpMethod !== 'POST') return respond(405, { message: 'POST only.' });
const user = await authorize(event.headers);
@@ -15,6 +53,7 @@ export async function handler(event) {
// Trusted host entry point for recovering an authenticated player's own job.
export async function generateForUser(input, userSub) {
+ if (!userSub) return respond(401, { message: 'Sign in to generate your fighter.' });
if (input?.action === 'withdraw') {
const { REGARDE_GATEWAY_URL: gateway, REGARDE_SUBJECT_SALT: salt, REGARDE_GATEWAY_TOKEN: token } = process.env;
if (!gateway || !salt || !token) return respond(503, { message: 'REGARDE is unavailable.' });
@@ -28,16 +67,24 @@ export async function generateForUser(input, userSub) {
signal: AbortSignal.timeout(10000) });
const result = await response.json();
if (!response.ok || !['withdrawn', 'nothing-to-withdraw'].includes(result.outcome)) return respond(502, { message: 'Withdrawal was not confirmed. Try again.' });
+ const { db } = await connect();
+ const subject = pseudonym(userSub, salt);
+ await db.collection('oskiewar-fighters').deleteOne({ _id: subject });
+ await db.collection('oskiewar-generation-jobs').deleteMany({ owner: userSub });
return respond(200, { status: result.outcome });
} catch { return respond(502, { message: 'Withdrawal was not confirmed. Try again.' }); }
}
- if (!['generate', 'status'].includes(input?.action) || !/^[a-f0-9]{64}$/.test(input?.hash) || typeof input.capability !== 'string' || input.capability.length > 20000)
+ if (!['generate', 'status', 'accept', 'account'].includes(input?.action) ||
+ (input.action !== 'account' && (!/^[a-f0-9]{64}$/.test(input?.hash) || typeof input.capability !== 'string' || input.capability.length > 20000)))
return respond(400, { message: 'A stored photo and capability are required.' });
const { REGARDE_GATEWAY_URL: gateway, REGARDE_SUBJECT_SALT: salt, REGARDE_GATEWAY_TOKEN: token, OPENAI_API_KEY: key } = process.env;
- if (!gateway || !salt || !token || !key) return respond(503, { message: 'Fighter generation is not configured.' });
+ if (!gateway || !salt || !token) return respond(503, { message: 'Fighter generation is not configured.' });
let jobs, id;
try {
const subject = pseudonym(userSub, salt), routes = gateRoutes(gateway);
+ const account = await fighterAccount(userSub);
+ if (!account.handle) return respond(409, { code: 'handle_required', message: 'Choose your AC handle before making a fighter.' });
+ if (input.action === 'account') return await restoreFighter(account, subject, routes, token);
const keysResponse = await fetch(routes.jwks, { signal: AbortSignal.timeout(10000) });
if (!keysResponse.ok) throw Object.assign(new Error('REGARDE keys are unavailable.'), { status: 503 });
const payload = verifyGenerationCapability(input.capability, await keysResponse.json(), subject);
@@ -45,26 +92,43 @@ export async function generateForUser(input, userSub) {
// The gate checks current grant/withdrawal, source category and receipt
// binding on every request, including cache reads and after generation.
const bytes = await read();
- const { db } = await connect();
+ const { db } = account;
jobs = db.collection('oskiewar-generation-jobs');
await jobs.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
id = createHash('sha256').update(JSON.stringify([subject, payload.receipt, input.hash, RECIPE])).digest('hex');
const existing = await jobs.findOne({ _id: id });
const result = job => respond(job.status === 'complete' ? 200 : job.status === 'running' ? 202 : 409,
- { id, status: job.status, ...(job.status === 'complete' ? { fighter: job.fighter,
+ { id, handle: account.handle, status: job.status, ...(job.status === 'complete' ? { fighter: job.fighter,
validUntil: Math.min(payload.exp * 1000, job.expiresAt.getTime()) } : {}),
...(job.status === 'failed' ? { message: 'This generation failed or was interrupted. It will not be charged again automatically.' } : {}) });
if (existing) {
if (existing.expiresAt.getTime() <= Date.now()) return respond(410, { message: 'This preview expired. Submit a new photo.' });
+ if (input.action === 'accept') {
+ if (existing.status !== 'complete') return respond(409, { message: 'Wait for a complete fighter before accepting it.' });
+ const prior = await account.fighters.findOne({ _id: subject });
+ const acceptedAt = prior?.fighter.hash === existing.fighter.hash ? prior.acceptedAt : new Date();
+ const expiresAt = prior?.fighter.hash === existing.fighter.hash ? prior.expiresAt : new Date(Date.now() + SAVED_MS);
+ await account.fighters.updateOne({ _id: subject }, { $set: {
+ owner: userSub, fighter: existing.fighter, acceptedAt, expiresAt,
+ } }, { upsert: true });
+ // Close a withdrawal during acceptance before releasing the selection.
+ try { await read(); } catch (error) {
+ await account.fighters.deleteOne({ _id: subject, 'fighter.hash': existing.fighter.hash });
+ throw error;
+ }
+ return respond(200, { handle: account.handle, status: 'accepted', fighter: existing.fighter,
+ acceptedAt, savedUntil: expiresAt, validUntil: Math.min(payload.exp * 1000, expiresAt.getTime()) });
+ }
if (existing.status === 'running' && Date.now() - existing.startedAt.getTime() > 120000) {
await jobs.updateOne({ _id: id, status: 'running' }, { $set: { status: 'failed' } });
existing.status = 'failed';
}
return result(existing);
}
- if (input.action === 'status') return respond(404, { message: 'No generation has started for this photo.' });
+ if (input.action !== 'generate') return respond(404, { message: 'No generation has started for this photo.' });
+ if (!key) return respond(503, { message: 'Fighter generation is not configured.' });
const expiresAt = new Date(payload.exp * 1000);
- try { await jobs.insertOne({ _id: id, status: 'running', startedAt: new Date(), expiresAt }); }
+ try { await jobs.insertOne({ _id: id, owner: userSub, status: 'running', startedAt: new Date(), expiresAt }); }
catch (error) { if (error.code === 11000) return respond(202, { id, status: 'running' }); throw error; }
const budget = db.collection('oskiewar-generation-budget');
await budget.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
diff --git a/system/tests/oskiewar-consent.test.mjs b/system/tests/oskiewar-consent.test.mjs
index d1c7172904..f5788d89b9 100644
--- a/system/tests/oskiewar-consent.test.mjs
+++ b/system/tests/oskiewar-consent.test.mjs
@@ -6,9 +6,9 @@
// rather than pass through. See the working proposal (vault:
// regarde/proposals/oskiewar-regarde) for the grant these fields become.
import assert from "node:assert/strict";
-import test from "node:test";
-import { readScope, frozenFields, SEPARATE }
- from "../netlify/functions/oskiewar-consent.mjs";
+import test, { mock } from "node:test";
+mock.module('../backend/authorization.mjs', { exports: { authorize: async () => ({ sub: 'auth0|fixture' }) } });
+const { readScope, frozenFields, SEPARATE, handler } = await import("../netlify/functions/oskiewar-consent.mjs");
// The narrowest answer the wall can send: look at me, make a picture, show
// nobody. Every test below is this with one thing changed.
@@ -25,6 +25,28 @@ test("the minimal answer is a grant", () => {
assert.deepEqual(scope.source, ["appearance"]);
});
+test('explicit consent choices retry identically but a new choice gets a new receipt key', async () => {
+ const saved = {...process.env}, originalFetch = globalThis.fetch, sent = [];
+ Object.assign(process.env, {REGARDE_GATEWAY_URL:'https://gate.invalid/v0/gateway', REGARDE_SUBJECT_SALT:'fixture', REGARDE_GATEWAY_TOKEN:'fixture'});
+ globalThis.fetch = async (url, options) => { sent.push(JSON.parse(options.body)); return Response.json({outcome:'refuse'}); };
+ const ask = requestId => handler({httpMethod:'POST',headers:{},body:JSON.stringify({...minimal, requestId})});
+ try {
+ await ask('11111111-1111-1111-1111-111111111111');
+ await ask('11111111-1111-1111-1111-111111111111');
+ await ask('22222222-2222-2222-2222-222222222222');
+ assert.equal(sent[0].idempotency_key, sent[1].idempotency_key);
+ assert.notEqual(sent[0].idempotency_key, sent[2].idempotency_key);
+ assert.equal((await ask('bad')).statusCode, 400);
+ assert.equal(sent.length, 3);
+ assert.ok(!JSON.stringify(sent).includes('auth0|fixture'));
+ } finally {
+ globalThis.fetch = originalFetch;
+ for (const key of ['REGARDE_GATEWAY_URL','REGARDE_SUBJECT_SALT','REGARDE_GATEWAY_TOKEN']) {
+ if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key];
+ }
+ }
+});
+
test("nothing defaults on", () => {
const { scope } = readScope(minimal);
// Marketing, merchandise and model training are the three a player is most
diff --git a/system/tests/oskiewar-generation.test.mjs b/system/tests/oskiewar-generation.test.mjs
index b5a1f04473..e270ec6bbd 100644
--- a/system/tests/oskiewar-generation.test.mjs
+++ b/system/tests/oskiewar-generation.test.mjs
@@ -32,11 +32,15 @@ test('malformed pictures never reach the model; appearance has no programmable c
assert.ok(fighterMesh(a).every(face => face.points.flat().every(Number.isFinite)));
});
const stores = new Map();
+stores.set('@handles', new Map([['auth0|fixture', { _id: 'auth0|fixture', handle: 'fixture' }],
+ ['auth0|other', { _id: 'auth0|other', handle: 'other' }]]));
function collection(name) {
if (!stores.has(name)) stores.set(name, new Map());
const rows = stores.get(name);
return {
createIndex: async () => {}, findOne: async q => rows.get(q._id),
+ deleteOne: async q => { const row = rows.get(q._id); if (row && (!q['fighter.hash'] || row.fighter?.hash === q['fighter.hash'])) rows.delete(q._id); },
+ deleteMany: async q => { for (const [id, row] of rows) if (row.owner === q.owner) rows.delete(id); },
insertOne: async row => { if(rows.has(row._id)) throw Object.assign(Error('duplicate'),{code:11000}); rows.set(row._id, row); },
updateOne: async (q, update, options={}) => {
let row = rows.get(q._id);
@@ -48,7 +52,7 @@ function collection(name) {
},
};
}
-mock.module('../backend/authorization.mjs', { exports: { authorize: async headers => headers?.authorization ? { sub: 'auth0|fixture' } : null } });
+mock.module('../backend/authorization.mjs', { exports: { authorize: async headers => headers?.authorization ? { sub: headers.authorization === 'Bearer other' ? 'auth0|other' : 'auth0|fixture' } : null } });
mock.module('../backend/database.mjs', { exports: { connect: async () => ({ db: { collection } }) } });
const { handler } = await import('../netlify/functions/oskiewar-generation.mjs');
const { pseudonym } = await import('../netlify/functions/oskiewar-consent.mjs');
@@ -61,6 +65,10 @@ test('real bridge releases one result, refuses revoked grants, and never regener
let modelCalls=0, mediaCalls=0, revoked=false, revokeDuringModel=false;
globalThis.fetch=async(url,options)=>{
if(url.endsWith('jwks.json')) return Response.json(jwks);
+ if(url.endsWith('/resume')) {
+ assert.deepEqual(JSON.parse(options.body), { subject, receipt: 'a'.repeat(64) });
+ return revoked ? Response.json({}, {status:403}) : Response.json({capability:{jws:token()}});
+ }
if(url.endsWith('/media')) {
mediaCalls++; const body=JSON.parse(options.body);
assert.equal(body.requireSource,'appearance'); assert.equal(body.requireOutput,'fighter_mesh');
@@ -75,11 +83,42 @@ test('real bridge releases one result, refuses revoked grants, and never regener
try {
assert.equal((await handler({...event(),headers:{}})).statusCode,401);
assert.equal((await handler(event('generate',token({outputs:['portrait']})))).statusCode,403);
+ assert.equal((await handler({...event(),headers:{authorization:'Bearer other'}})).statusCode,403, 'a capability cannot be used by another AC account');
+ const profile = stores.get('@handles').get('auth0|fixture');
+ stores.get('@handles').delete('auth0|fixture');
+ assert.equal((await handler(event())).statusCode,409, 'choose a handle before generation');
+ stores.get('@handles').set('auth0|fixture',profile);
assert.equal(mediaCalls,0); assert.equal(modelCalls,0);
const generated=await handler(event()); assert.equal(generated.statusCode,200);
- assert.equal(JSON.parse(generated.body).fighter.appearance.hairStyle,'short');
+ const fighter = JSON.parse(generated.body).fighter;
+ assert.equal(fighter.appearance.hairStyle,'short');
+ assert.equal(JSON.parse(generated.body).handle, '@fixture');
+ const accept = event('accept');
+ accept.body = JSON.stringify({ ...JSON.parse(accept.body), handle: '@other', fighter: { damage: 999 } });
+ const accepted = await handler(accept);
+ assert.equal(accepted.statusCode, 200);
+ assert.equal(JSON.parse(accepted.body).status, 'accepted');
+ assert.deepEqual(JSON.parse(accepted.body).fighter, fighter);
+ const savedUntil = JSON.parse(accepted.body).savedUntil;
+ assert.equal(JSON.parse((await handler(event('accept'))).body).savedUntil, savedUntil, 'repeat acceptance does not extend retention');
+ const accountEvent = {httpMethod:'POST', headers:{authorization:'Bearer fixture'}, body:JSON.stringify({action:'account'})};
+ assert.equal(JSON.parse((await handler({...accountEvent,headers:{authorization:'Bearer other'}})).body).status, 'empty', 'another account cannot recover this fighter');
+ stores.get('@handles').get('auth0|fixture').handle = 'renamed';
+ const restored = JSON.parse((await handler(accountEvent)).body);
+ assert.equal(restored.handle, '@renamed');
+ assert.equal(restored.fighter.hash, fighter.hash, 'handle rename preserves ownership');
+ assert.equal(modelCalls, 1, 'restoring an accepted fighter never calls the model');
+ delete process.env.OPENAI_API_KEY;
+ assert.equal((await handler(accountEvent)).statusCode, 200, 'recovery needs no model credentials');
+ process.env.OPENAI_API_KEY = 'fixture';
+ stores.get('@handles').get('auth0|fixture').handle = 'fixture';
assert.equal((await handler(event())).statusCode,200); assert.equal(modelCalls,1);
+ stores.get('oskiewar-fighters').get(subject).expiresAt = new Date(1);
+ assert.equal(JSON.parse((await handler(accountEvent)).body).status, 'empty', 'expired saved fighter is unavailable before TTL cleanup');
+ assert.equal((await handler(event('accept'))).statusCode,200);
revoked=true; assert.equal((await handler(event('status'))).statusCode,403); assert.equal(modelCalls,1);
+ assert.equal(JSON.parse((await handler(accountEvent)).body).status, 'empty');
+ assert.equal(stores.get('oskiewar-fighters').size, 0, 'revoked fighter is removed');
revoked=false; revokeDuringModel=true;
assert.equal((await handler(event('generate',token({receipt:'b'.repeat(64)})))).statusCode,403);
const jobs=[...stores.get('oskiewar-generation-jobs').values()];
diff --git a/system/tests/oskiewar-turnstile-live.mjs b/system/tests/oskiewar-turnstile-live.mjs
new file mode 100644
index 0000000000..2d179082cb
--- /dev/null
+++ b/system/tests/oskiewar-turnstile-live.mjs
@@ -0,0 +1,95 @@
+// Run on Poorslice with a designated/approved AC account. No API mocks.
+// This creates a grant, spends one generation, and withdraws ALL Oskiewar
+// material for that account. Never run without explicit test-account consent.
+import assert from 'node:assert/strict';
+import { readFile, mkdir } from 'node:fs/promises';
+import puppeteer from 'puppeteer';
+import sharp from 'sharp';
+
+if (process.env.OSKIEWAR_LIVE_WITHDRAW !== '1') throw Error('Requires OSKIEWAR_LIVE_WITHDRAW=1 and an approved test account.');
+const base = process.env.OSKIEWAR_URL || 'https://oskiewar.com';
+const dir = process.env.OSKIEWAR_SHOTS || '/tmp/oskiewar-mosono-live';
+await mkdir(dir,{recursive:true});
+const auth = JSON.parse(await readFile(process.env.AC_TOKEN_FILE || `${process.env.HOME}/.ac-token`,'utf8'));
+const access = auth.access_token || auth.accessToken || auth.token;
+const info = await fetch('https://hi.aesthetic.computer/userinfo',{headers:{Authorization:`Bearer ${access}`}});
+assert.equal(info.status,200,'test account must have a live Auth0 session');
+const {sub} = await info.json();
+const fixture = dir+'/synthetic-fighter.png';
+await sharp(Buffer.from(``)).png().toFile(fixture);
+const browser = await puppeteer.launch({headless:true,executablePath:process.env.CHROME_BIN||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+const page = await browser.newPage();
+const evidence=[]; let source, grant, acceptedHash;
+page.on('response',async response=>{
+ const url=new URL(response.url());
+ if(!url.pathname.startsWith('/api/oskiewar-'))return;
+ evidence.push({path:url.pathname,status:response.status()});
+ try {
+ const data=await response.json();
+ if(url.pathname==='/api/oskiewar-consent'&&data.capability?.jws)grant=data.capability.jws;
+ if(url.pathname==='/api/oskiewar-submission')source=data.manifest?.find(x=>x.source==='appearance')?.hash?.replace(/^sha256:/,'')||source;
+ }catch{}
+});
+const wait=fn=>page.waitForFunction(fn,{timeout:120000});
+const post=body=>page.evaluate(async body=>{
+ const token=await globalThis.__oskiewarAccount.bearer();
+ const r=await fetch('/api/oskiewar-generation',{method:'POST',headers:{'Content-Type':'application/json',authorization:'Bearer '+token},body:JSON.stringify(body)});
+ return {status:r.status,body:await r.json()};
+},body);
+const open=async()=>{
+ await page.evaluate(()=>globalThis.__oskiewarWizard.open());
+ await wait(()=>document.querySelector('#wizard-go')&&!document.querySelector('#wizard-go').disabled);
+};
+try {
+ await page.setViewport({width:1200,height:900});
+ await page.goto(base,{waitUntil:'domcontentloaded'});
+ assert.equal(await page.evaluate(async()=>{
+ const r=await fetch('/api/oskiewar-generation',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'account'})});return r.status;
+ }),401,'anonymous requests must be refused');
+ // Restore a real, existing authenticated session; this does not simulate OTP delivery.
+ await page.evaluateOnNewDocument(({sub,access})=>{
+ if(location.origin==='https://oskiewar.com'&&!sessionStorage.getItem('turnstile-session-loaded')){
+ localStorage.setItem('ac-otp-session',JSON.stringify({sub,access,expires:Date.now()+3600000}));
+ sessionStorage.setItem('turnstile-session-loaded','1');
+ }
+ },{sub,access});
+ await page.reload({waitUntil:'domcontentloaded'});
+ await wait(()=>globalThis.__oskiewarAccount?.ready&&globalThis.__oskiewarAccount?.signedIn&&globalThis.__oskiewarWizard);
+ const handle=await page.evaluate(()=>globalThis.__oskiewarAccount.handle);
+ assert.ok(handle.startsWith('@'));
+ await open();
+ // The approved test cleanup makes repeated live runs deterministic.
+ await page.click('button::-p-text(Withdraw my material)');
+ await wait(()=>document.querySelector('#wizard-note').textContent.startsWith('Withdrawn.'));
+ await page.click('#wizard-back');await open();
+ await page.click('#wizard-go');
+ await page.waitForSelector('input[name="appearance"][type="file"]',{timeout:30000});
+ await page.screenshot({path:dir+'/01-granted.png'});
+ await (await page.$('input[name="appearance"][type="file"]')).uploadFile(fixture);
+ await page.click('#wizard-go');
+ await wait(()=>document.querySelector('#wizard-go').textContent==='Accept & use in practice'||document.querySelector('#wizard-note').className==='trouble');
+ assert.equal(await page.$eval('#wizard-go',el=>el.textContent),'Accept & use in practice',await page.$eval('#wizard-note',el=>el.textContent));
+ assert.equal(await page.evaluate(()=>!!globalThis.__oskiewarFighterAppearance),false,'review cannot equip before acceptance');
+ await page.screenshot({path:dir+'/02-generated.png'});
+ await page.click('#wizard-go');await wait(()=>!!globalThis.__oskiewarFighterAppearance);
+ const account=await post({action:'account'});assert.equal(account.status,200);assert.equal(account.body.status,'accepted');
+ assert.equal(account.body.handle.toLowerCase(),handle.toLowerCase());acceptedHash=account.body.fighter.hash;
+ await page.click('#wizard-back');await page.keyboard.press('Space');
+ await wait(()=>globalThis.__oskiewarTouch?.screen==='game');
+ await page.keyboard.down('ArrowRight');await new Promise(r=>setTimeout(r,1000));await page.keyboard.up('ArrowRight');
+ assert.ok(await page.evaluate(()=>Array.isArray(globalThis.__oskiewarFighterAppearance?.appearance.skin)));
+ await page.screenshot({path:dir+'/03-local-practice.png'});
+ await page.reload({waitUntil:'domcontentloaded'});
+ await wait(()=>globalThis.__oskiewarAccount?.ready&&globalThis.__oskiewarWizard);
+ await open();await wait(()=>document.querySelector('#wizard-go').textContent==='Use in practice');
+ assert.equal((await post({action:'account'})).body.fighter.hash,acceptedHash);
+ await page.setViewport({width:390,height:844});await page.screenshot({path:dir+'/04-mobile-restored.png'});
+ assert.equal((await post({action:'status',hash:source,capability:grant.slice(0,-4)+'AAAA'})).status,403,'tampered capability refused');
+ await page.click('button::-p-text(Withdraw my material)');await wait(()=>document.querySelector('#wizard-note').textContent.startsWith('Withdrawn.'));
+ assert.equal(await page.evaluate(()=>!!globalThis.__oskiewarFighterAppearance),false);
+ assert.equal((await post({action:'account'})).body.status,'empty');
+ assert.equal((await post({action:'status',hash:source,capability:grant})).status,403,'withdrawn grant refuses old preview');
+ await page.screenshot({path:dir+'/05-withdrawn.png'});
+ console.log(JSON.stringify({result:'PASS',handle,checks:['anonymous refusal','real authenticated session','REGARDE grant','protected upload','real model generation','review','server acceptance','local practice','reload recovery','mobile','tamper refusal','withdrawal','revoked preview refusal'],requests:evidence},null,2));
+}catch(error){await page.screenshot({path:dir+'/failure.png'}).catch(()=>{});throw error;}
+finally{await browser.close();}
diff --git a/system/tests/oskiewar-turnstile.browser.mjs b/system/tests/oskiewar-turnstile.browser.mjs
new file mode 100644
index 0000000000..67cdd3050a
--- /dev/null
+++ b/system/tests/oskiewar-turnstile.browser.mjs
@@ -0,0 +1,74 @@
+// Browser regression on Poorslice. Synthetic API fixtures; the live journey
+// uses oskiewar-turnstile-live.mjs separately, without API interception.
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { readFile, mkdir } from 'node:fs/promises';
+import puppeteer from 'puppeteer';
+
+const shotDir = process.env.OSKIEWAR_SHOTS || '/tmp/oskiewar-mosono-browser';
+await mkdir(shotDir, { recursive: true });
+const appearance = {skin:'#d7a079',hair:'#392819',shirt:'#446688',pants:'#223344',shoes:'#eeeeee',hairStyle:'short',beard:true,glasses:true,sleeves:'short'};
+const fighter = {version:1,recipe:'oskiewar-capsule-fighter-v1',hash:'a'.repeat(64),appearance};
+let saved = false, denied = false, missingHandle = false, modelCalls = 0, acceptedCalls = 0;
+const result = status => ({status,handle:'@fixture',fighter,validUntil:Date.now()+600000});
+const server = createServer(async (req,res) => {
+ const send = (body,status=200,type='application/json') => {res.writeHead(status,{'content-type':type});res.end(type==='application/json'?JSON.stringify(body):body);};
+ if(req.url === '/') return send(``,200,'text/html');
+ if(req.url.endsWith('.mjs')) return send(await readFile(new URL('../../xbox/live'+req.url,import.meta.url),'utf8'),200,'text/javascript');
+ if(!req.url.startsWith('/api/')) return send({},404);
+ let raw='';for await(const c of req)raw+=c;
+ const body=JSON.parse(raw);
+ if(req.url==='/api/oskiewar-consent') return send(denied?{outcome:'refuse',capability:null}:{outcome:'allow',capability:{jws:'fixture',sources:['appearance']}});
+ if(req.url==='/api/oskiewar-submission') return body.action==='status'?send({},404):send({manifest:[{source:'appearance',hash:'sha256:'+'b'.repeat(64)}]},201);
+ if(missingHandle) return send({code:'handle_required',message:'Choose your AC handle before making a fighter.'},409);
+ if(body.action==='account') return send(saved?result('accepted'):{status:'empty',handle:'@fixture'});
+ if(body.action==='generate'){modelCalls++;return send(result('complete'));}
+ if(body.action==='accept'){acceptedCalls++;saved=true;return send(result('accepted'));}
+ if(body.action==='withdraw'){saved=false;return send({status:'withdrawn'});}
+ send({},400);
+});
+await new Promise(r=>server.listen(0,'127.0.0.1',r));
+const browser=await puppeteer.launch({headless:true,executablePath:process.env.CHROME_BIN||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+const page=await browser.newPage();
+const errors=[];page.on('pageerror',e=>errors.push(e.message));
+const wait=predicate=>page.waitForFunction(predicate,{timeout:20000});
+const note=()=>page.$eval('#wizard-note',el=>el.textContent);
+const open=async()=>{await page.click('#open');await wait(()=>document.querySelector('#wizard-go')&&!document.querySelector('#wizard-go').disabled);};
+try {
+ await page.setViewport({width:1100,height:850});
+ await page.goto(`http://127.0.0.1:${server.address().port}`);
+ await open();
+ assert.equal(await page.$eval('#wizard-title',el=>el.textContent),'Add @fixture');
+ assert.equal(await page.$('input[value="voice"]'),null);
+ await page.click('#wizard-go');await page.waitForSelector('input[type=file]');
+ const upload=await page.$('input[type=file]');await upload.uploadFile(new URL('./oskiewar-generation.test.mjs',import.meta.url).pathname);
+ await page.click('#wizard-go');await wait(()=>document.querySelector('#wizard-go').textContent==='Accept & use in practice');
+ assert.equal(saved,false);assert.equal(await page.evaluate(()=>!!globalThis.__oskiewarFighterAppearance),false);
+ await page.screenshot({path:shotDir+'/01-review.png'});
+ await page.click('#wizard-go');await wait(()=>!!globalThis.__oskiewarFighterAppearance);
+ assert.equal(acceptedCalls,1);assert.equal(saved,true);assert.match(await note(),/@fixture/);
+ await page.reload();await open();
+ await wait(()=>document.querySelector('#wizard-go').textContent==='Use in practice');
+ await page.click('#wizard-go');await wait(()=>!!globalThis.__oskiewarFighterAppearance);
+ assert.equal(modelCalls,1);assert.equal(acceptedCalls,1);
+ await new Promise(r=>setTimeout(r,16000));
+ assert.ok(await page.evaluate(()=>Array.isArray(globalThis.__oskiewarFighterAppearance.appearance.skin)));
+ await page.setViewport({width:390,height:844});await page.screenshot({path:shotDir+'/02-mobile-saved.png'});
+ await page.evaluate(()=>{window.signedIn=false;});
+ await wait(()=>!globalThis.__oskiewarFighterAppearance);
+ await page.evaluate(()=>{window.signedIn=true;});
+ await page.click('button::-p-text(Withdraw my material)');
+ await wait(()=>document.querySelector('#wizard-note').textContent.startsWith('Withdrawn.'));
+ assert.equal(saved,false);
+ await page.reload();denied=true;await open();await page.click('#wizard-go');
+ await wait(()=>document.querySelector('#wizard-note').textContent.includes('Not granted'));
+ assert.equal(await page.$('input[type=file]'),null);assert.equal(modelCalls,1);
+ await page.screenshot({path:shotDir+'/03-refusal.png'});
+ await page.reload();missingHandle=true;await open();
+ assert.equal(await page.evaluate(()=>globalThis.__oskiewarAccountDoor),'handle');
+ assert.deepEqual(errors,[]);
+ console.log('PASS: review, explicit acceptance, handle binding, reload recovery, renewal colors, sign-out, withdrawal, refusal, missing handle; desktop + mobile.');
+}finally{await browser.close();await new Promise(r=>server.close(r));}
diff --git a/xbox/OSKIEWAR-RELEASE.md b/xbox/OSKIEWAR-RELEASE.md
index a25ed385b3..f3969a005c 100644
--- a/xbox/OSKIEWAR-RELEASE.md
+++ b/xbox/OSKIEWAR-RELEASE.md
@@ -1,5 +1,28 @@
# Oskiewar release
+The web character generator uses an authenticated AC account and its current
+handle. REGARDE receives only a purpose-scoped pseudonym. An explicit Allow
+opens photo upload; generation requires a live, photo-bound capability. Review
+and acceptance are separate: acceptance saves the server's fighter for 24 hours,
+with shorter play authority renewed against the standing grant. Reopening Add
+yourself restores it without another model call. Handle changes retain ownership.
+Withdrawal removes the account's saved fighter and source material. While equipped,
+authority is checked every 15 seconds; a failed check clears the selection,
+and its current expiry is also enforced at render time. Presentation is
+limited to local practice; online play uses the standard fighter. The REGARDE
+gate still signs with its documented demo key.
+
+Run browser checks on Poorslice under Node 24:
+
+```sh
+node system/tests/oskiewar-turnstile.browser.mjs
+```
+
+That browser regression uses synthetic API fixtures. A live test must also
+exercise the deployed AC bridge, REGARDE gate, storage, and generation provider;
+fixture results alone are not proof of the live turnstile. Use a designated test
+account or obtain permission before withdrawing an account's existing material.
+
`npm run oskiewar:deploy` is the only canonical live release command. It
fingerprints `xbox/live/oskiewar.js`, refuses uncommitted game source, records
an obligation for web, iOS, and Xbox, deploys the web first, verifies its
diff --git a/xbox/live/oskiewar-wizard.mjs b/xbox/live/oskiewar-wizard.mjs
index e0cdc56800..ee1ee5d369 100644
--- a/xbox/live/oskiewar-wizard.mjs
+++ b/xbox/live/oskiewar-wizard.mjs
@@ -17,7 +17,7 @@
// retry loop, because asking the same desk the same question until it says yes
// is how a consent wall becomes a nag screen.
-import { mountFighterPreview } from "./oskiewar-fighter.mjs";
+import { mountFighterPreview, validateFighter } from "./oskiewar-fighter.mjs";
const ENDPOINT = "/api/oskiewar-consent";
@@ -25,7 +25,6 @@ const ENDPOINT = "/api/oskiewar-consent";
// stated on the card, not an unseen expansion of permission.
const MATERIALS = [
{ value: "appearance", label: "Photo", on: true },
- { value: "voice", label: "Voice" },
];
export function scopeForMedia({ photo, voice }) {
@@ -88,7 +87,7 @@ export default function mountWizard({ sfx = () => {}, bearer = async () => null
- Cloud speech and cloud narration each require separate permission. Whisper cloud speech sends each finished recording through AC to OpenAI for transcription and word timing. It uses 40 braincells per recorded second, drawn from the daily allowance first and then purchased credits. Failed transcriptions are refunded. AC stores billing receipts and an audio digest to prevent duplicate charges, but does not store the recording or transcript for this service. A retry result stays in server memory for up to one minute. Earlier personal builds may also use live OpenAI transcription. Cloud narration sends caption text through Aesthetic Computer to ElevenLabs. Without those permissions, speech recognition and narration use the device when available. Typed input remains available.
+ One first-use AI permission covers creation, Whisper speech transcription, and captions sent to ElevenLabs when optional Jeffrey narration is selected. Earlier builds ask for these permissions separately. Whisper cloud speech sends each finished recording through AC to OpenAI for transcription and word timing. It uses 40 braincells per recorded second, drawn from the daily allowance first and then purchased credits. Failed transcriptions are refunded. AC stores billing receipts and an audio digest to prevent duplicate charges, but does not store the recording or transcript for this service. A retry result stays in server memory for up to one minute. Earlier personal builds may also use live OpenAI transcription. Cloud narration sends caption text through Aesthetic Computer to ElevenLabs. Without those permissions, speech recognition and narration use the device when available. Typed input remains available.