Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

aa: phone-side claude bridge — disk piece + macbook server, gated to @jeffrey

Adds `aa.mjs` (admin-only chat piece that streams Server-Sent Events from
the bridge) plus `help/bridge/aa-bridge.mjs` (local Express server that
spawns `claude --print --output-format stream-json` and pipes events back).
Reaches the macbook from the phone via the existing help.aesthetic.computer
proxy + autossh reverse tunnel — no new public infrastructure.

Also: PWA shortcut for /aa, SCORE.md backend bullet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+743
+1
SCORE.md
··· 59 59 **Backend** 60 60 - `session-server/` — Real-time multiplayer (Socket.io) 61 61 - `lith/` — Production monolith deploy (Express + Caddy on a DigitalOcean VPS, pulled from the tangled knot `git@knot.aesthetic.computer:aesthetic.computer/core` via `lith/deploy.fish`). Express adapts the handlers in `system/netlify/functions/` as routes — the `netlify/functions/` path is historical; Netlify is no longer the host. 62 + - `help/bridge/` — Local Express bridge on @jeffrey's macbook that spawns the host `claude` CLI and streams it as SSE; reached publicly through `help.aesthetic.computer` via the existing droplet proxy + autossh reverse tunnel. Powers the `aa` piece (admin-only phone-side chat with the macbook's claude). Auto-runs under launchd as `computer.aesthetic.aa-bridge` and `computer.aesthetic.aa-tunnel`. 62 63 - Authentication and data storage 63 64 64 65 **Languages**
+36
help/bridge/.env.example
··· 1 + # aa-bridge configuration 2 + # Copy to .env and fill in. 3 + 4 + # Auth0 sub of @jeffrey (the only allowed user) 5 + ADMIN_SUB=auth0|63effeeb2a7d55f8098d62f9 6 + 7 + # Auth0 tenant 8 + AUTH0_DOMAIN=aesthetic.us.auth0.com 9 + 10 + # Port the bridge listens on (matches macbook side of help tunnel) 11 + AA_PORT=3004 12 + 13 + # Working directory the spawned `claude` runs in 14 + AA_WORK_DIR=/Users/aesthetic/aesthetic-computer 15 + 16 + # Path to the claude binary 17 + CLAUDE_BIN=/Users/aesthetic/.local/bin/claude 18 + 19 + # Permission mode for the spawned claude 20 + # bypassPermissions = autonomous (admin-only setup) 21 + # acceptEdits = auto-accept file edits, prompt for bash 22 + # default = prompt for everything (will hang the bridge) 23 + AA_PERMISSION_MODE=bypassPermissions 24 + 25 + # Optional model override. Empty = claude default (opus on Max plan). 26 + # For cheaper phone use try "sonnet" or "haiku". 27 + AA_MODEL=sonnet 28 + 29 + # Where per-user session ids are persisted 30 + AA_SESSION_FILE=/Users/aesthetic/.aa-bridge/sessions.json 31 + 32 + # Comma-separated CORS allow list 33 + AA_ALLOWED_ORIGINS=https://aesthetic.computer,https://hi.aesthetic.computer,http://localhost:8888 34 + 35 + # Set AA_DEV=1 to bypass all auth checks for local smoke tests. 36 + # AA_DEV=1
+309
help/bridge/aa-bridge.mjs
··· 1 + #!/usr/bin/env node 2 + // aa-bridge — phone-to-claude bridge for @jeffrey 3 + // 4 + // POST /api/chat with Auth0 bearer 5 + // → validates token against Auth0 /userinfo 6 + // → checks sub against ADMIN_SUB env var 7 + // → spawns `claude --print --output-format stream-json` (resumes per-user session) 8 + // → streams claude events back as Server-Sent Events 9 + // 10 + // POST /api/reset — clear stored session for this user (bearer required) 11 + // GET /health — liveness probe 12 + // GET /api/session — return current stored session id for this user 13 + 14 + import http from "http"; 15 + import { spawn } from "child_process"; 16 + import { readFile, writeFile, mkdir } from "fs/promises"; 17 + import { existsSync } from "fs"; 18 + import { homedir } from "os"; 19 + import { dirname, join } from "path"; 20 + import { randomUUID } from "crypto"; 21 + 22 + const PORT = parseInt(process.env.AA_PORT || "3004", 10); 23 + const ADMIN_SUB = process.env.ADMIN_SUB; 24 + const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || "aesthetic.us.auth0.com"; 25 + const CLAUDE_BIN = process.env.CLAUDE_BIN || "claude"; 26 + const WORK_DIR = process.env.AA_WORK_DIR || join(homedir(), "aesthetic-computer"); 27 + const SESSION_FILE = 28 + process.env.AA_SESSION_FILE || join(homedir(), ".aa-bridge", "sessions.json"); 29 + const PERMISSION_MODE = process.env.AA_PERMISSION_MODE || "bypassPermissions"; 30 + const MODEL = process.env.AA_MODEL || ""; // "" = use claude default; "sonnet" / "haiku" / full id 31 + const DEV_BYPASS = process.env.AA_DEV === "1"; 32 + const ALLOWED_ORIGINS = ( 33 + process.env.AA_ALLOWED_ORIGINS || 34 + "https://aesthetic.computer,https://hi.aesthetic.computer,http://localhost:8888" 35 + ).split(","); 36 + 37 + if (!ADMIN_SUB && !DEV_BYPASS) { 38 + console.error("ADMIN_SUB env var required (e.g. auth0|...). Set AA_DEV=1 to bypass for local testing."); 39 + process.exit(1); 40 + } 41 + 42 + // ───────── Auth0 token validation (cached) ───────── 43 + const tokenCache = new Map(); 44 + const TOKEN_TTL = 5 * 60 * 1000; 45 + 46 + async function validateBearer(authHeader) { 47 + if (DEV_BYPASS) return ADMIN_SUB || "dev|local"; 48 + if (!authHeader?.startsWith("Bearer ")) return null; 49 + const token = authHeader.slice(7); 50 + const cached = tokenCache.get(token); 51 + if (cached && cached.expires > Date.now()) return cached.sub; 52 + try { 53 + const res = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, { 54 + headers: { Authorization: authHeader }, 55 + }); 56 + if (!res.ok) return null; 57 + const user = await res.json(); 58 + tokenCache.set(token, { sub: user.sub, expires: Date.now() + TOKEN_TTL }); 59 + return user.sub; 60 + } catch (err) { 61 + console.error("auth0 userinfo failed:", err.message); 62 + return null; 63 + } 64 + } 65 + 66 + // ───────── Per-user session id persistence ───────── 67 + async function loadSessions() { 68 + if (!existsSync(SESSION_FILE)) return {}; 69 + try { 70 + return JSON.parse(await readFile(SESSION_FILE, "utf8")); 71 + } catch { 72 + return {}; 73 + } 74 + } 75 + 76 + async function saveSessions(sessions) { 77 + await mkdir(dirname(SESSION_FILE), { recursive: true }); 78 + await writeFile(SESSION_FILE, JSON.stringify(sessions, null, 2)); 79 + } 80 + 81 + async function getSessionId(sub) { 82 + const s = await loadSessions(); 83 + return s[sub] || null; 84 + } 85 + 86 + async function setSessionId(sub, sessionId) { 87 + const s = await loadSessions(); 88 + s[sub] = sessionId; 89 + await saveSessions(s); 90 + } 91 + 92 + async function clearSession(sub) { 93 + const s = await loadSessions(); 94 + delete s[sub]; 95 + await saveSessions(s); 96 + } 97 + 98 + // ───────── SSE helpers ───────── 99 + function corsHeaders(origin) { 100 + const allow = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]; 101 + return { 102 + "Access-Control-Allow-Origin": allow, 103 + "Access-Control-Allow-Credentials": "true", 104 + "Access-Control-Allow-Headers": "Authorization,Content-Type", 105 + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", 106 + }; 107 + } 108 + 109 + function sse(res, event, data) { 110 + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); 111 + } 112 + 113 + function readJsonBody(req) { 114 + return new Promise((resolve, reject) => { 115 + let body = ""; 116 + req.on("data", (c) => (body += c)); 117 + req.on("end", () => { 118 + if (!body) return resolve({}); 119 + try { 120 + resolve(JSON.parse(body)); 121 + } catch (err) { 122 + reject(err); 123 + } 124 + }); 125 + req.on("error", reject); 126 + }); 127 + } 128 + 129 + // ───────── claude spawn ───────── 130 + function spawnClaude(message, sessionId) { 131 + const args = [ 132 + "--print", 133 + "--output-format", 134 + "stream-json", 135 + "--verbose", 136 + "--permission-mode", 137 + PERMISSION_MODE, 138 + ]; 139 + if (MODEL) args.push("--model", MODEL); 140 + if (sessionId) { 141 + args.push("--resume", sessionId); 142 + } else { 143 + args.push("--session-id", randomUUID()); 144 + } 145 + args.push(message); 146 + return spawn(CLAUDE_BIN, args, { 147 + cwd: WORK_DIR, 148 + env: { ...process.env }, 149 + stdio: ["ignore", "pipe", "pipe"], 150 + }); 151 + } 152 + 153 + // ───────── /api/chat ───────── 154 + async function handleChat(req, res, origin) { 155 + const sub = await validateBearer(req.headers.authorization); 156 + if (!sub) { 157 + res.writeHead(401, { "Content-Type": "application/json", ...corsHeaders(origin) }); 158 + res.end(JSON.stringify({ error: "invalid token" })); 159 + return; 160 + } 161 + if (!DEV_BYPASS && sub !== ADMIN_SUB) { 162 + res.writeHead(403, { "Content-Type": "application/json", ...corsHeaders(origin) }); 163 + res.end(JSON.stringify({ error: "not admin" })); 164 + return; 165 + } 166 + 167 + let payload; 168 + try { 169 + payload = await readJsonBody(req); 170 + } catch { 171 + res.writeHead(400, { "Content-Type": "application/json", ...corsHeaders(origin) }); 172 + res.end(JSON.stringify({ error: "invalid json" })); 173 + return; 174 + } 175 + 176 + const message = payload.message; 177 + if (!message || typeof message !== "string") { 178 + res.writeHead(400, { "Content-Type": "application/json", ...corsHeaders(origin) }); 179 + res.end(JSON.stringify({ error: "message (string) required" })); 180 + return; 181 + } 182 + if (payload.reset === true) await clearSession(sub); 183 + 184 + res.writeHead(200, { 185 + "Content-Type": "text/event-stream", 186 + "Cache-Control": "no-cache", 187 + Connection: "keep-alive", 188 + "X-Accel-Buffering": "no", 189 + ...corsHeaders(origin), 190 + }); 191 + 192 + const sessionId = await getSessionId(sub); 193 + sse(res, "start", { sessionId, cwd: WORK_DIR }); 194 + 195 + const child = spawnClaude(message, sessionId); 196 + let buffer = ""; 197 + let detectedSessionId = sessionId; 198 + 199 + // Heartbeat to keep proxies from closing the SSE 200 + const heartbeat = setInterval(() => res.write(": ping\n\n"), 15_000); 201 + 202 + child.stdout.on("data", (chunk) => { 203 + buffer += chunk.toString(); 204 + const lines = buffer.split("\n"); 205 + buffer = lines.pop(); 206 + for (const line of lines) { 207 + if (!line.trim()) continue; 208 + try { 209 + const event = JSON.parse(line); 210 + if (event.session_id) detectedSessionId = event.session_id; 211 + sse(res, "claude", event); 212 + } catch { 213 + sse(res, "claude", { type: "raw", text: line }); 214 + } 215 + } 216 + }); 217 + 218 + child.stderr.on("data", (chunk) => { 219 + sse(res, "stderr", { text: chunk.toString() }); 220 + }); 221 + 222 + child.on("close", async (code) => { 223 + clearInterval(heartbeat); 224 + if (detectedSessionId && detectedSessionId !== sessionId) { 225 + await setSessionId(sub, detectedSessionId); 226 + } 227 + sse(res, "done", { code, sessionId: detectedSessionId }); 228 + res.end(); 229 + }); 230 + 231 + child.on("error", (err) => { 232 + clearInterval(heartbeat); 233 + sse(res, "error", { message: err.message }); 234 + res.end(); 235 + }); 236 + 237 + req.on("close", () => { 238 + clearInterval(heartbeat); 239 + if (!child.killed) child.kill("SIGTERM"); 240 + }); 241 + } 242 + 243 + // ───────── server ───────── 244 + const server = http.createServer(async (req, res) => { 245 + const origin = req.headers.origin || ""; 246 + 247 + if (req.method === "OPTIONS") { 248 + res.writeHead(204, corsHeaders(origin)); 249 + res.end(); 250 + return; 251 + } 252 + 253 + if (req.url === "/health") { 254 + res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) }); 255 + res.end( 256 + JSON.stringify({ 257 + ok: true, 258 + service: "aa-bridge", 259 + version: "0.1.0", 260 + cwd: WORK_DIR, 261 + permissionMode: PERMISSION_MODE, 262 + devBypass: DEV_BYPASS, 263 + }), 264 + ); 265 + return; 266 + } 267 + 268 + if (req.url === "/api/chat" && req.method === "POST") { 269 + return handleChat(req, res, origin); 270 + } 271 + 272 + if (req.url === "/api/session" && req.method === "GET") { 273 + const sub = await validateBearer(req.headers.authorization); 274 + if (!sub || (!DEV_BYPASS && sub !== ADMIN_SUB)) { 275 + res.writeHead(sub ? 403 : 401, corsHeaders(origin)); 276 + res.end(); 277 + return; 278 + } 279 + const sid = await getSessionId(sub); 280 + res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) }); 281 + res.end(JSON.stringify({ sessionId: sid })); 282 + return; 283 + } 284 + 285 + if (req.url === "/api/reset" && req.method === "POST") { 286 + const sub = await validateBearer(req.headers.authorization); 287 + if (!sub || (!DEV_BYPASS && sub !== ADMIN_SUB)) { 288 + res.writeHead(sub ? 403 : 401, corsHeaders(origin)); 289 + res.end(); 290 + return; 291 + } 292 + await clearSession(sub); 293 + res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders(origin) }); 294 + res.end(JSON.stringify({ ok: true })); 295 + return; 296 + } 297 + 298 + res.writeHead(404, corsHeaders(origin)); 299 + res.end(); 300 + }); 301 + 302 + server.listen(PORT, () => { 303 + console.log(`aa-bridge listening on :${PORT}`); 304 + console.log(` cwd: ${WORK_DIR}`); 305 + console.log(` claude: ${CLAUDE_BIN}`); 306 + console.log(` perm mode: ${PERMISSION_MODE}`); 307 + console.log(` admin sub: ${ADMIN_SUB ? ADMIN_SUB.slice(0, 14) + "…" : "(dev bypass)"}`); 308 + console.log(` sessions: ${SESSION_FILE}`); 309 + });
+10
help/bridge/package.json
··· 1 + { 2 + "name": "aa-bridge", 3 + "version": "0.1.0", 4 + "type": "module", 5 + "main": "aa-bridge.mjs", 6 + "scripts": { 7 + "start": "node --env-file-if-exists=.env aa-bridge.mjs", 8 + "dev": "AA_DEV=1 node --env-file-if-exists=.env aa-bridge.mjs" 9 + } 10 + }
+372
system/public/aesthetic.computer/disks/aa.mjs
··· 1 + // aa, 26.04.20 2 + // Phone-side chat with AA — your remote Claude on the macbook, 3 + // reached via help.aesthetic.computer. @jeffrey only. 4 + 5 + const { floor, min, max } = Math; 6 + 7 + const ENDPOINT_PROD = "https://help.aesthetic.computer"; 8 + const ENDPOINT_DEV = "https://help.aesthetic.computer"; // same; bridge enforces auth 9 + const PROMPT_PLACEHOLDER = "ask aa…"; 10 + 11 + let endpoint = ENDPOINT_PROD; 12 + let token = null; 13 + let userHandle = null; 14 + let userSub = null; 15 + 16 + let isAdmin = false; 17 + let authChecked = false; 18 + let authError = null; 19 + 20 + let log = []; // [{ role: "user"|"assistant"|"tool"|"system"|"error", text }] 21 + let pending = false; // true while a response is streaming 22 + let pendingText = ""; // partial assistant text being streamed 23 + let abortCtrl = null; 24 + let scrollY = 0; // pixels scrolled (positive = scrolled up to see older) 25 + 26 + let input = null; 27 + let sendBtn = null; 28 + let resetBtn = null; 29 + 30 + const PALETTE = { 31 + bg: [10, 10, 14], 32 + bgLight: [248, 246, 240], 33 + fg: [232, 232, 240], 34 + fgLight: [22, 22, 28], 35 + user: [110, 200, 255], 36 + assistant: [220, 220, 230], 37 + tool: [240, 200, 90], 38 + system: [140, 140, 160], 39 + error: [255, 100, 100], 40 + pending: [180, 180, 90], 41 + }; 42 + 43 + function colors(dark, kind) { 44 + if (kind === "bg") return dark ? PALETTE.bg : PALETTE.bgLight; 45 + if (kind === "fg") return dark ? PALETTE.fg : PALETTE.fgLight; 46 + return PALETTE[kind]; 47 + } 48 + 49 + async function boot({ api, ui, screen, cursor, hud, handle, user, params }) { 50 + cursor("native"); 51 + hud.labelBack(); 52 + 53 + userHandle = handle(); 54 + userSub = user?.sub || null; 55 + 56 + // Allow override: aa:dev → use a local bridge if you ever run one with public CORS 57 + if (params[0] === "dev") endpoint = ENDPOINT_DEV; 58 + 59 + log.push({ role: "system", text: "AA — your remote claude on the macbook." }); 60 + 61 + if (!userSub) { 62 + authChecked = true; 63 + authError = "log in first"; 64 + return; 65 + } 66 + 67 + try { 68 + token = await api.authorize(); 69 + if (!token) { 70 + authError = "no token"; 71 + authChecked = true; 72 + return; 73 + } 74 + // The bridge will reject if sub doesn't match ADMIN_SUB. 75 + // We probe /api/session to confirm before showing the input. 76 + const probe = await fetch(`${endpoint}/api/session`, { 77 + headers: { Authorization: `Bearer ${token}` }, 78 + }); 79 + if (probe.status === 200) { 80 + const data = await probe.json(); 81 + isAdmin = true; 82 + authChecked = true; 83 + if (data.sessionId) { 84 + log.push({ role: "system", text: `resuming session ${data.sessionId.slice(0, 8)}…` }); 85 + } else { 86 + log.push({ role: "system", text: "fresh session." }); 87 + } 88 + } else if (probe.status === 403) { 89 + authError = "not admin (this piece is @jeffrey-only)"; 90 + authChecked = true; 91 + } else { 92 + authError = `bridge said ${probe.status}`; 93 + authChecked = true; 94 + } 95 + } catch (err) { 96 + authError = `bridge unreachable: ${err.message}`; 97 + authChecked = true; 98 + } 99 + 100 + if (!isAdmin) return; 101 + 102 + input = new ui.TextInput(api, "", async (text) => { 103 + text = (text || "").trim(); 104 + if (!text) return; 105 + if (text === "/reset") { 106 + await reset(); 107 + input.text = ""; 108 + return; 109 + } 110 + if (text === "/clear") { 111 + log = [{ role: "system", text: "cleared." }]; 112 + input.text = ""; 113 + return; 114 + } 115 + if (text === "/cancel" || text === "/stop") { 116 + if (abortCtrl) abortCtrl.abort(); 117 + input.text = ""; 118 + return; 119 + } 120 + await send(text); 121 + input.text = ""; 122 + }); 123 + } 124 + 125 + async function reset() { 126 + try { 127 + await fetch(`${endpoint}/api/reset`, { 128 + method: "POST", 129 + headers: { Authorization: `Bearer ${token}` }, 130 + }); 131 + log.push({ role: "system", text: "session reset." }); 132 + } catch (err) { 133 + log.push({ role: "error", text: `reset failed: ${err.message}` }); 134 + } 135 + } 136 + 137 + async function send(text) { 138 + if (pending) { 139 + log.push({ role: "system", text: "still thinking — type /cancel to stop." }); 140 + return; 141 + } 142 + log.push({ role: "user", text }); 143 + pending = true; 144 + pendingText = ""; 145 + scrollY = 0; // jump to bottom on new turn 146 + abortCtrl = new AbortController(); 147 + 148 + try { 149 + const res = await fetch(`${endpoint}/api/chat`, { 150 + method: "POST", 151 + headers: { 152 + "Content-Type": "application/json", 153 + Authorization: `Bearer ${token}`, 154 + }, 155 + body: JSON.stringify({ message: text }), 156 + signal: abortCtrl.signal, 157 + }); 158 + if (!res.ok) { 159 + const errText = await res.text().catch(() => `${res.status}`); 160 + log.push({ role: "error", text: `${res.status}: ${errText.slice(0, 200)}` }); 161 + return; 162 + } 163 + await consumeSSE(res); 164 + } catch (err) { 165 + if (err.name === "AbortError") { 166 + log.push({ role: "system", text: "cancelled." }); 167 + } else { 168 + log.push({ role: "error", text: err.message }); 169 + } 170 + } finally { 171 + pending = false; 172 + pendingText = ""; 173 + abortCtrl = null; 174 + } 175 + } 176 + 177 + async function consumeSSE(res) { 178 + const reader = res.body.getReader(); 179 + const decoder = new TextDecoder(); 180 + let buf = ""; 181 + 182 + while (true) { 183 + const { done, value } = await reader.read(); 184 + if (done) break; 185 + buf += decoder.decode(value, { stream: true }); 186 + let idx; 187 + while ((idx = buf.indexOf("\n\n")) !== -1) { 188 + const block = buf.slice(0, idx); 189 + buf = buf.slice(idx + 2); 190 + handleSSEBlock(block); 191 + } 192 + } 193 + } 194 + 195 + function handleSSEBlock(block) { 196 + let event = "message"; 197 + let data = ""; 198 + for (const line of block.split("\n")) { 199 + if (line.startsWith("event:")) event = line.slice(6).trim(); 200 + else if (line.startsWith("data:")) data += line.slice(5).trim(); 201 + else if (line.startsWith(":")) continue; // comment / heartbeat 202 + } 203 + if (!data) return; 204 + let payload; 205 + try { payload = JSON.parse(data); } catch { return; } 206 + 207 + if (event === "claude") handleClaudeEvent(payload); 208 + else if (event === "stderr") { 209 + // Surface only non-empty, non-warning stderr (claude prints noisy warnings) 210 + const t = (payload.text || "").trim(); 211 + if (t && !t.startsWith("Warning:")) log.push({ role: "system", text: t }); 212 + } else if (event === "error") { 213 + log.push({ role: "error", text: payload.message || "error" }); 214 + } else if (event === "done") { 215 + if (pendingText) { 216 + log.push({ role: "assistant", text: pendingText }); 217 + pendingText = ""; 218 + } 219 + } 220 + } 221 + 222 + function handleClaudeEvent(ev) { 223 + // stream-json from `claude --print --output-format stream-json` 224 + if (ev.type === "assistant" && ev.message?.content) { 225 + for (const block of ev.message.content) { 226 + if (block.type === "text" && block.text) { 227 + // Accumulate; commit as one message on `done` to avoid duplicates 228 + pendingText += block.text; 229 + } else if (block.type === "tool_use") { 230 + const summary = `${block.name}${block.input ? " " + summarizeToolInput(block.input) : ""}`; 231 + log.push({ role: "tool", text: `→ ${summary}` }); 232 + } 233 + } 234 + } else if (ev.type === "result") { 235 + // Final result event has the canonical answer; replace any partial. 236 + if (ev.result) { 237 + pendingText = ev.result; 238 + } 239 + } 240 + } 241 + 242 + function summarizeToolInput(input) { 243 + if (typeof input === "string") return input.slice(0, 80); 244 + // Pull the most likely useful field 245 + const k = input.command || input.file_path || input.path || input.pattern || input.query || input.url; 246 + if (k) return String(k).slice(0, 80); 247 + return JSON.stringify(input).slice(0, 80); 248 + } 249 + 250 + // ───────── paint ───────── 251 + 252 + const PAD = 6; 253 + const LINE_H = 12; 254 + const HEADER_H = 18; 255 + const INPUT_H = 64; // TextInput needs room for its prompt + gutter buttons 256 + 257 + function wrap(text, charW, maxW) { 258 + const maxChars = max(8, floor(maxW / charW)); 259 + const lines = []; 260 + for (const para of String(text).split("\n")) { 261 + if (!para) { lines.push(""); continue; } 262 + let i = 0; 263 + while (i < para.length) { 264 + lines.push(para.slice(i, i + maxChars)); 265 + i += maxChars; 266 + } 267 + } 268 + return lines; 269 + } 270 + 271 + function paint($) { 272 + const { wipe, ink, write, screen, dark } = $; 273 + const w = screen.width; 274 + const h = screen.height; 275 + 276 + wipe(...colors(dark, "bg")); 277 + 278 + // Header 279 + ink(...colors(dark, "fg")).write("AA", { x: PAD, y: PAD }); 280 + const status = pending ? "thinking…" : (isAdmin ? "ready" : (authError || "checking…")); 281 + const statusColor = pending ? PALETTE.pending : (isAdmin ? PALETTE.user : PALETTE.error); 282 + ink(...statusColor).write(status, { x: PAD + 32, y: PAD }); 283 + 284 + if (userHandle) { 285 + const tag = `@${userHandle}`; 286 + ink(...PALETTE.system).write(tag, { x: w - tag.length * 6 - PAD, y: PAD }); 287 + } 288 + 289 + // Divider 290 + ink(...PALETTE.system, 100).box(0, HEADER_H - 2, w, 1, "fill"); 291 + 292 + // Scrollback area bounds 293 + const scrollTop = HEADER_H; 294 + const scrollBottom = h - INPUT_H - PAD; 295 + const scrollH = scrollBottom - scrollTop; 296 + const charW = 6; 297 + const innerW = w - PAD * 2; 298 + 299 + // Build flat list of {color, text} lines, in display order (top→bottom) 300 + const lines = []; 301 + for (const entry of log) { 302 + const color = colors(dark, "fg"); 303 + let prefix = ""; 304 + let textColor; 305 + if (entry.role === "user") { prefix = "› "; textColor = PALETTE.user; } 306 + else if (entry.role === "assistant") { prefix = " "; textColor = PALETTE.assistant; } 307 + else if (entry.role === "tool") { prefix = " "; textColor = PALETTE.tool; } 308 + else if (entry.role === "error") { prefix = "! "; textColor = PALETTE.error; } 309 + else { prefix = "· "; textColor = PALETTE.system; } 310 + const wrapped = wrap(prefix + entry.text, charW, innerW); 311 + for (const ln of wrapped) lines.push({ text: ln, color: textColor }); 312 + lines.push({ text: "", color: textColor }); // blank between entries 313 + } 314 + 315 + // Live partial assistant text 316 + if (pending && pendingText) { 317 + const wrapped = wrap(" " + pendingText + "▌", charW, innerW); 318 + for (const ln of wrapped) lines.push({ text: ln, color: PALETTE.assistant }); 319 + } else if (pending && !pendingText) { 320 + lines.push({ text: " …", color: PALETTE.pending }); 321 + } 322 + 323 + // Render bottom-aligned with scroll offset 324 + const totalH = lines.length * LINE_H; 325 + const maxScroll = max(0, totalH - scrollH); 326 + scrollY = min(scrollY, maxScroll); 327 + const startY = scrollBottom - totalH + scrollY; 328 + 329 + for (let i = 0; i < lines.length; i++) { 330 + const y = startY + i * LINE_H; 331 + if (y < scrollTop - LINE_H || y > scrollBottom) continue; 332 + const ln = lines[i]; 333 + if (!ln.text) continue; 334 + ink(...ln.color).write(ln.text, { x: PAD, y }); 335 + } 336 + 337 + // Mask area below input zone 338 + ink(...colors(dark, "bg")).box(0, scrollBottom + 1, w, h - scrollBottom); 339 + 340 + // Input 341 + if (input) { 342 + const frameY = h - INPUT_H; 343 + ink(...PALETTE.system, 80).box(0, frameY, w, 1, "fill"); 344 + input.paint($, false, { x: 0, y: frameY + 1, width: w, height: INPUT_H - 1 }); 345 + } else if (authChecked && !isAdmin) { 346 + const msg = authError || "not authorized"; 347 + ink(...PALETTE.error).write(msg, { center: "x", y: h - 16, screen }); 348 + } 349 + 350 + if (pending) $.needsPaint(); 351 + } 352 + 353 + function act({ api, event: e, screen }) { 354 + // Scroll the log when dragging in the scrollback area (not the input frame) 355 + if (e.is("draw") && e.dy) { 356 + const inInputFrame = e.y && e.y >= screen.height - INPUT_H; 357 + if (!inInputFrame) scrollY = max(0, scrollY + e.dy); 358 + } 359 + if (e.is("keyboard:down:pageup")) scrollY += LINE_H * 5; 360 + if (e.is("keyboard:down:pagedown")) scrollY = max(0, scrollY - LINE_H * 5); 361 + 362 + if (input) input.act(api); 363 + } 364 + 365 + function meta() { 366 + return { 367 + title: "AA", 368 + desc: "Talk to your macbook's claude from anywhere. @jeffrey only.", 369 + }; 370 + } 371 + 372 + export { boot, paint, act, meta };
+15
system/public/manifest.json
··· 37 37 "form_factor": "wide", 38 38 "label": "Aesthetic Computer Prompt" 39 39 } 40 + ], 41 + "shortcuts": [ 42 + { 43 + "name": "AA", 44 + "short_name": "AA", 45 + "description": "Talk to your remote claude on the macbook", 46 + "url": "/aa", 47 + "icons": [ 48 + { 49 + "src": "https://oven.aesthetic.computer/icon/192x192/aa.png", 50 + "sizes": "192x192", 51 + "type": "image/png" 52 + } 53 + ] 54 + } 40 55 ] 41 56 }