diff --git a/README.md b/README.md index a2cce21b4e..95488da3ac 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ prompt, orange highlight, magenta handle) and shows the signed-in `@handle` and the piece currently being worked on in the header. Inside the TUI: `/login`, `/logout`, `/whoami`, `/publish [file] [slug]`, -`/piece [name]`, `/runtime [mjs|lisp|processing]`, `/backend [claude|codex]`, +`/autopublish [on|off]`, `/piece [name]`, `/runtime [mjs|lisp|processing]`, +`/backend [claude|codex]`, `/model [name]`, `/qr`, `/live`, `/new`, `/clear`, `/help`, `/quit`. Press `ctrl-c` to interrupt a running turn or exit while idle. @@ -124,12 +125,23 @@ at `https://aesthetic.computer/@handle/slug`, exactly like the web prompt's uploads the source, and reads the live file back before reporting the URL. Writing a file under `disks/` does not publish it, and the engine is told so. +`--autopublish` does that on every save instead, so the session's URL is live +the whole time the piece is being worked on and the last edit is still there +after the terminal closes. It coalesces — a publish runs once the saves stop, +never more than one at a time, and never twice for the same bytes — and it +flushes the pending save on exit. Off by default, since it writes to a public +route under your own handle: turn it on per session with the flag or +`/autopublish`, or for every session with `AESTHETIC_CODE_AUTOPUBLISH=1`, which +`--no-autopublish` overrides. With it on the engine is told the piece is +already live and told not to ask you to publish. + ```sh aesthetic login aesthetic whoami aesthetic publish system/public/aesthetic.computer/disks/smiley.mjs ac --runtime lisp ac --backend codex +ac --autopublish ``` ```sh diff --git a/docs/local-contract.md b/docs/local-contract.md index d044d68c48..5da2479d68 100644 --- a/docs/local-contract.md +++ b/docs/local-contract.md @@ -21,11 +21,22 @@ and the session token to `aesthetic.computer` only when the user runs `/publish`. The engine bridge never receives the token; publishing is an interface action, not an agent tool. +Auto-publish (`--autopublish`, `AESTHETIC_CODE_AUTOPUBLISH=1`, `/autopublish`) +is the one way that becomes repeated rather than per-command: with it on, the +interface publishes the session's piece a couple of seconds after every save, +and the last save is flushed on the way out. It is off by default and has to be +turned on per session or per environment, because it writes to a public route +under the user's own handle. It changes when publishing happens, not who does +it — still the interface, still the same one file, and the token still never +reaches the bridge. The agent cannot turn it on, and the transcript keeps one +line naming the URL each publish went to. + ## Live piece boundary The session's piece is pushed to `aesthetic.computer/run` on a private code -channel every time its file changes, and re-sent unchanged every few seconds so -that a phone scanning the QR code later still receives it. That request carries +channel every time its file changes. The session server retains the last +message a channel received, so a phone that scans the code later still receives +the piece without the interface re-announcing it. That request carries the piece's source and the channel token, and nothing else: no account token, no workspace paths, no conversation. The channel token is random per session and is never reused. diff --git a/src/tui.mjs b/src/tui.mjs index 7a53337f0e..6b30b8b395 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -52,6 +52,12 @@ const state = { // copy of it. `/qr` still brings it back — on a machine with no Slab menu // bar the code in here is the only way onto a phone. showQr: false, + // Actions run without stopping to ask, and are reported once they have. The + // engine has no OS sandbox of its own, so what still holds a session in is + // narrower than a prompt: file tools confined to this directory, the fetching + // tools withheld, and none of the user's own settings or servers in scope. + // `/ask on` trades the speed back for the question. + autoAllow: true, entries: [ { id: "privacy", @@ -412,11 +418,22 @@ function handleRequest(request) { request.method === "item/commandExecution/requestApproval" || request.method === "item/fileChange/requestApproval" ) { - state.approval = { - id: request.id, - method: request.method, - subject: approvalSubject(request.method, request.params || {}), - }; + const subject = approvalSubject(request.method, request.params || {}); + // Allowed without asking, by default. The first real session spent two of + // its two hours and nineteen minutes parked on prompts with nobody sitting + // in front of them, and that is the failure this default answers. + // + // The interface stays the approver rather than handing the decision down to + // the engine, so every action still arrives here and is still written into + // the transcript: you read what ran instead of being asked about it first. + // `/ask on` puts the question back for the rest of the session. + if (state.autoAllow) { + engine.respond(request.id, { decision: "accept" }); + addEntry("notice", `Ran: ${subject}`); + redraw(); + return; + } + state.approval = { id: request.id, method: request.method, subject }; slabSession.awaitingInput( request.method === "item/commandExecution/requestApproval" ? "aesthetic code needs command approval" @@ -637,7 +654,7 @@ async function submitInput() { if (command === "/help") { addEntry( "notice", - "/login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", + "/login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -685,6 +702,21 @@ async function submitInput() { } return redraw(); } + // The way back. Auto-allow is the default, so this is the control that + // matters most in here: one word returns the question for the rest of the + // session, and the notice says which way it went rather than assuming the + // reader remembers which way it was. + if (command === "/ask") { + const want = rest.trim().toLowerCase(); + state.autoAllow = want === "on" ? false : want === "off" ? true : !state.autoAllow; + addEntry( + "notice", + state.autoAllow + ? "Running without asking · /ask on to be asked first" + : "Asking before each action · /ask off to stop asking", + ); + return redraw(); + } if (command === "/open") { // The code is for a phone. This is for the machine the session is already // running on: same URL, same channel, same autorun — the piece opens in a