From 52a60539b4fe2ae6e67c825c3baec3d9be4412bb Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 11 Sep 2026 10:31:02 -0400 Subject: [PATCH] aesthetic-code: the address answers from the first second, and typing ahead is allowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things that made the session's own address a lie. The code on the rock now points at the piece's published URL, and nothing published it until the first edit — so a fresh session advertised `@jeffrey/dafu` and `@jeffrey/dafu` 404ed, every time. Auto-publish was on and waiting for a save that had not happened yet. The blank publishes now, as soon as the handle resolves, which is the earliest moment there is a route to publish to. That reverses a deliberate rule: an untouched session used to leave nothing behind, out there or in the workspace. It still leaves nothing in the workspace — the local blank is discarded on exit if it was never edited — but the published copy stays. A published blank is a smaller problem than a code that resolves to nothing, now that the code is the piece's real address rather than a throwaway channel. The second is that a prompt typed during a running turn was thrown away with "A turn is already running." Thinking ahead of the machine is the normal way to use this — you read the first half of an answer and already know the next instruction — and the sentence was discarded, so you waited and retyped it. Those lines queue now and start when the turn reports completed, one at a time, routed back through the same path a typed line takes so a queued `/command` is still a command. A turn that fails before it starts drains the queue too; it emits no turn/completed and the queue would otherwise wait for a turn that never comes. Ctrl-c drops the queue. An interrupt is a decision about everything you were about to say, not only the turn that happened to be running, and having three stale instructions fire after you stopped the machine is the wrong reading. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01A1xZ1BonXHyds6EzF5H9Hb --- aesthetic-code/src/tui.mjs | 69 ++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/aesthetic-code/src/tui.mjs b/aesthetic-code/src/tui.mjs index ae95896519..f2979d3c54 100755 --- a/aesthetic-code/src/tui.mjs +++ b/aesthetic-code/src/tui.mjs @@ -55,6 +55,11 @@ const state = { cursor: 0, history: [], historyIndex: 0, + // Prompts typed while a turn was running. Thinking ahead of the machine is + // the normal way to use this thing — you read the first half of an answer and + // already know the next instruction — and refusing that keystroke threw the + // sentence away and made you wait to retype it. + queued: [], approval: null, account: session.label(), piece: "", @@ -408,6 +413,14 @@ function handleNotification({ method, params = {} }) { if (params.turn?.status === "interrupted") slabSession.interrupted(); else if (params.turn?.status === "failed") slabSession.awaitingInput("aesthetic code turn failed"); else slabSession.complete(); + // An interrupt is a decision about everything you were going to say, not + // just the turn that was running, so ctrl-c drops the queue with it. + if (params.turn?.status === "interrupted" && state.queued.length) { + const dropped = state.queued.length; + state.queued.length = 0; + addEntry("notice", `Interrupted · dropped ${dropped} queued`); + } + drainQueue(); break; } case "warning": @@ -492,11 +505,30 @@ function answerApproval(character) { // ── account + publish commands ────────────────────────────────────────── +// The address on the rock is the piece's published address, so it has to exist +// before anyone scans it — including in the first seconds of a session, before +// a single edit. Publishing the blank is what makes the code on the rock point +// at a page instead of a 404. +// +// It cannot run at startup: the handle arrives asynchronously, and without one +// there is no route to publish to. So it is armed here instead and fires on +// whichever comes first — a session that was already signed in, or the moment a +// sign-in resolves. +let blankPublished = false; + +function publishBlankOnce() { + if (blankPublished) return; + if (!autopublish.enabled || autopublishBlocker()) return; + blankPublished = true; + autopublish.note(live.source()); +} + function refreshAccount(announce = false) { const previous = state.account; state.account = session.label(); slabSession.identity(session.handle); live.handle = session.handle || ""; + publishBlankOnce(); if (announce && previous !== state.account) { addEntry("notice", session.signedIn ? `Signed in as ${state.account}` : "Signed out"); } @@ -551,8 +583,10 @@ function commandAutopublish(argumentText) { } else { addEntry("notice", `Auto-publish on · every save goes to ${autopublishRoute()}`); // Turning it on mid-session should publish what is already written, not - // wait for the next keystroke to notice the piece exists. - if (!live.pristine) autopublish.note(live.source()); + // wait for the next keystroke to notice the piece exists. That includes an + // untouched blank: the point of publishing is that the address answers. + blankPublished = true; + autopublish.note(live.source()); } // How publishing works is part of the developer instructions, and those are // written once when the thread opens. A mid-session toggle is real @@ -655,6 +689,20 @@ async function commandModel(rest) { return restartEngine("Model"); } +// Start the next queued line, if the turn that just ended left one. Routed back +// through the same path a typed line takes, so a queued `/command` still behaves +// like a command rather than becoming a prompt. +function drainQueue() { + if (state.busy || !state.queued.length) return; + const next = state.queued.shift(); + state.input = next; + state.cursor = Array.from(next).length; + submitInput().catch((error) => { + addEntry("error", errorText(error)); + redraw(); + }); +} + async function submitInput() { const text = state.input.trim(); state.input = ""; @@ -805,7 +853,9 @@ async function submitInput() { } if (state.busy) { - addEntry("error", "A turn is already running. Press ctrl-c to interrupt it."); + state.queued.push(text); + const place = state.queued.length > 1 ? ` (${state.queued.length} queued)` : ""; + addEntry("notice", `Queued${place} · ${text}`); return redraw(); } @@ -824,6 +874,9 @@ async function submitInput() { state.status = "failed"; addEntry("error", errorText(error)); redraw(); + // A turn that never started emits no turn/completed, so the queue has to be + // let go from here too or it waits for a turn that will never come. + drainQueue(); } } @@ -920,9 +973,13 @@ session.watch().on("change", () => { // Mint this session's blank piece and the QR code that opens it on a phone. live.create(); live.watch(liveError); -// Every save that reaches the phone is a candidate for the public URL too. The -// blank is not: an untouched session should leave nothing behind, out there or -// in the workspace. +publishBlankOnce(); +// Every save that reaches the phone is a candidate for the public URL too, and +// so is the blank. That reverses an earlier rule — an untouched session used to +// leave nothing behind, out there or in the workspace — because the address on +// the rock is now the published one, and a code that resolves to a 404 until +// someone types is worse than a published blank. The local file is still +// discarded on exit if it was never edited; the published copy stays. live.on("push", () => { if (live.pristine || autopublishBlocker()) return; autopublish.note(live.source()); -- 2.51.2