From 8d1db9f77761f62e8a41366de34323e335371932 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 28 Sep 2026 13:56:27 -0700 Subject: [PATCH] Aesel 0.8.4: open windows announce a new version, and /update restarts into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window left open now finds out about a newer Aesel: a release install asks the feed every half hour; a checkout notices its package.json move past the version it started on. It says so in the middle of the window — a banner flashing red and yellow — until /update or /update later. /update restarts in place (process.execve) on the same thread; a release install downloads and verifies first, as before. Also: workspace commands run in their own process group, and a timeout or interrupt kills the whole group. Killing only the shell orphaned a grep -r that held the pipe open, so a tool call ran for 17 minutes. Co-Authored-By: Claude Opus 5.5 --- aesel/package.json | 2 +- aesel/src/render.mjs | 27 +++++++++++- aesel/src/tui.mjs | 78 +++++++++++++++++++++++------------ aesel/src/workspace-tools.mjs | 15 +++++-- 4 files changed, 90 insertions(+), 32 deletions(-) diff --git a/aesel/package.json b/aesel/package.json index 6ac8d78fac..177d94b7a8 100644 --- a/aesel/package.json +++ b/aesel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.8.3", + "version": "0.8.4", "private": true, "type": "module", "scripts": { diff --git a/aesel/src/render.mjs b/aesel/src/render.mjs index a1b0a07c95..6a8bdbdcae 100644 --- a/aesel/src/render.mjs +++ b/aesel/src/render.mjs @@ -860,7 +860,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const trayMuted = useColor ? fg(light ? [110, 110, 110] : [150, 150, 150]) : ""; const onTray = (row) => (useColor ? `${trayGround}${String(row).replace(/\x1b\[0m/g, `\x1b[0m${trayGround}`).replace(new RegExp(color.ground.replace(/[[\]\\]/g, "\\$&"), "g"), trayGround).replace(new RegExp(color.muted.replace(/[[\]\\]/g, "\\$&"), "g"), trayMuted)}${trayGround}` : row); const trayRows = shape.bottom.map((name) => onTray(name === "bar" ? bar : name === "gap" ? fit("", width) : rows[name]?.() ?? "")); - return paintDropdown([...shown, ...trayRows].slice(0, height), state, width, height, useColor, shape) + return paintUpdate(paintDropdown([...shown, ...trayRows].slice(0, height), state, width, height, useColor, shape), state, width, height - shape.bottom.length, useColor) .map((line) => `${ground}${fit(line, width)}${reset}`) .join("\n"); } @@ -957,6 +957,31 @@ export function dropdownGeometry(state, width, height, shape = state.layout || { return { x, top, count, start, index, items, labelWidth, detailWidth, boxWidth, title: drop.title || "provider · model" }; } +// A newer Aesel is ready: a banner in the middle of the conversation, flashing +// red and yellow on a slow beat so a window left open for a week still says so. +// It holds until /update restarts into the new code or /update later hides it. +function paintUpdate(rows, state, width, area, useColor) { + const update = state.update; + if (!update || update.dismissed) return rows; + const lines = [ + `✦ Aesel ${update.version} is ready ✦`, + update.kind === "checkout" ? "/update restarts into it · this thread comes with you" : "/update installs it and restarts · this thread comes with you", + "/update later hides this", + ]; + const inner = Math.min(width - 4, Math.max(...lines.map((line) => textWidth(line))) + 4); + if (inner < 20 || area < lines.length + 2) return rows; + const x = Math.max(0, Math.floor((width - inner) / 2)); + const top = Math.max(0, Math.floor((area - lines.length - 2) / 2)); + const red = Math.floor((state.nowMs ?? Date.now()) / 650) % 2 === 0; + const paintRow = (text) => useColor + ? `${bg(red ? [215, 40, 50] : [250, 205, 40])}${fg(red ? [255, 250, 235] : [40, 20, 10])}\x1b[1m${fit(` ${text}`, inner)}\x1b[22m${color.reset}${color.ground}` + : fit(` ${text}`, inner); + const box = ["", ...lines.map((line) => " ".repeat(Math.max(0, Math.floor((inner - 2 - textWidth(line)) / 2))) + line), ""]; + const out = [...rows]; + box.forEach((text, i) => { const y = top + i; if (y < out.length) out[y] = `${fit(out[y], x)}${paintRow(text)}`; }); + return out; +} + function paintDropdown(rows, state, width, height, useColor, shape) { const g = dropdownGeometry(state, width, height, shape); if (!g || g.top < 0) return rows; diff --git a/aesel/src/tui.mjs b/aesel/src/tui.mjs index 8862d27307..f10c4a5d4a 100755 --- a/aesel/src/tui.mjs +++ b/aesel/src/tui.mjs @@ -53,7 +53,7 @@ import { LivePiece } from "./live.mjs"; import { exampleConfig, resolveProfile } from "./profile.mjs"; import { BOTTOM_ROWS, Layout, STATUS_FACTS } from "./layout.mjs"; import { DraftBroadcast } from "./draft-broadcast.mjs"; -import { applyUpdate, checkForUpdate, currentVersion, installed } from "./updates.mjs"; +import { applyUpdate, checkForUpdate, currentVersion, installed, isNewer } from "./updates.mjs"; import { publishPiece } from "./publish.mjs"; import { syncPictureWip, pictureWipAddress } from "./picture-wip.mjs"; import { publishPicture, publishedPicture } from "./publish-picture.mjs"; @@ -308,6 +308,8 @@ const state = { spend: { thread: "", tokens: 0, usd: 0, billed: false }, // The account's braincells (free today + bought), shared by every session. braincells: null, + // A newer Aesel ready to run: { version, kind: "checkout" | "release", dismissed }. + update: null, qr: null, // The prompt rock in the menu bar draws this session's code at real pixel // resolution, so the transcript does not spend seventeen rows on a worse @@ -1149,6 +1151,16 @@ async function refreshBraincells() { } catch {} } +// Become the new code without closing the window: shut down as a quit would, +// then replace this process with a fresh one on the same thread. +let restartArgs = null; +async function restartInPlace() { + const args = arguments_.filter((a, i, all) => a !== "--resume" && all[i - 1] !== "--resume"); + if (engine.threadId) args.push("--resume", engine.threadId); + restartArgs = args; + return finish(0); +} + async function finish(code = 0) { if (closing || finishing) return; finishing = true; @@ -1217,6 +1229,9 @@ startNativeGamepad(); // Keep the saved thread’s piece, including an untouched blank, resumable. // All durable saves/uploads above have settled; sockets must not delay desktop exit. if(desktopSessionPath) process.exit(code); + if (restartArgs && typeof process.execve === "function") { + process.execve(process.execPath, [process.execPath, fileURLToPath(import.meta.url), ...restartArgs], process.env); + } } function errorText(error) { @@ -2242,26 +2257,27 @@ async function submitInput(submittedText, submittedMessages = null) { if (command === "/restart") return requestDesktop("restart"); if (command === "/update" && desktopSessionPath) return requestDesktop("update"); if (command === "/update") { - if (!installed()) { - addEntry("notice", `aesel ${currentVersion()} — running from a checkout, so there is nothing to update. Use git.`); + if (rest.trim() === "later") { + if (state.update) state.update.dismissed = true; return redraw(); } - addEntry("notice", "Checking for a newer aesel…"); - redraw(); + if (state.busy) { flash("wait for this turn to finish, then /update"); return redraw(); } try { - const update = await checkForUpdate({ force: true }); - if (!update) { - addEntry("notice", `aesel ${currentVersion()} is the latest.`); + if (installed()) { + const update = await checkForUpdate({ force: true }); + if (!update) { flash(`aesel ${currentVersion()} is the latest`); return redraw(); } + flash(`installing aesel ${update.version}…`); + redraw(); + await applyUpdate({ manifest: update }); + } else if (!(state.update || isNewer(currentVersion(), startedVersion))) { + flash(`aesel ${currentVersion()} · a checkout: git pull, then /update`); return redraw(); } - addEntry("notice", `Installing aesel ${update.version}…`); - redraw(); - const version = await applyUpdate({ manifest: update }); - addEntry("notice", `aesel ${version} installed. Restart to run it.`); + return restartInPlace(); } catch (error) { addEntry("error", `Update failed: ${errorText(error)}`); + return redraw(); } - return redraw(); } if (command === "/versions") { if(state.medium!=='piece'){addEntry('notice',(await artifacts.versions()).map(v=>`v${v.version} · ${v.summary}`).join('\n'));return redraw();} @@ -2900,19 +2916,29 @@ if (state.medium === "piece" && networked) live.watch(liveError); if (networked) publishBlankOnce(); refreshAccount(); -// 🆕 Ask once a day, in the background, and say nothing unless there is news. -// Deliberately not automatic: replacing the tool someone is mid-sentence with -// is the wrong kind of surprise, and a line they can ignore costs nothing. -checkForUpdate() - .then((update) => { - if (!update) return; - addEntry( - "notice", - `aesel ${update.version} is out — you have ${update.current}. Run /update to install it.`, - ); - redraw(); - }) - .catch(() => {}); +// 🆕 A newer Aesel, announced in the window that is already open. A release +// install asks the feed every half hour; a checkout notices its own +// package.json moving past the version this window started on (a git pull). +// Nothing installs itself: the banner says so, and /update does it. +const startedVersion = currentVersion(); +function announceUpdate(version, kind) { + if (!version || (state.update && !isNewer(version, state.update.version))) return; + state.update = { version, kind, dismissed: false }; + redraw(); +} +async function lookForUpdate(force = false) { + if (installed()) { + const update = await checkForUpdate({ force }).catch(() => null); + if (update) announceUpdate(update.version, "release"); + } else { + const onDisk = currentVersion(); + if (onDisk && startedVersion && isNewer(onDisk, startedVersion)) announceUpdate(onDisk, "checkout"); + } +} +void lookForUpdate(); +setInterval(() => void lookForUpdate(installed()), installed() ? 30 * 60 * 1000 : 60 * 1000).unref(); +// The banner flashes on a slow beat; the beat only runs while it shows. +setInterval(() => { if (state.update && !state.update.dismissed) { state.nowMs = Date.now(); redraw(); } }, 650).unref(); // 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 diff --git a/aesel/src/workspace-tools.mjs b/aesel/src/workspace-tools.mjs index 84ca94572a..a1bfb2ebfe 100644 --- a/aesel/src/workspace-tools.mjs +++ b/aesel/src/workspace-tools.mjs @@ -112,23 +112,30 @@ function clip(text) { return `${text.slice(0, MAX_OUTPUT)}\n… cut at ${MAX_OUTPUT} of ${text.length} characters`; } +// Each command runs in its own process group, and a timeout or an interrupt +// kills the whole group. Killing only the shell left its children (a grep -r +// over a whole repo) holding the output pipe open, so the call never ended. function run(command, args, { cwd, signal, timeout = COMMAND_TIMEOUT }) { return new Promise((done) => { - const child = spawn(command, args, { cwd, signal, env: process.env }); + const child = spawn(command, args, { cwd, env: process.env, detached: true }); + const killGroup = () => { try { process.kill(-child.pid, "SIGKILL"); } catch {} }; + signal?.addEventListener("abort", killGroup, { once: true }); let output = ""; const collect = (chunk) => { if (output.length < MAX_OUTPUT * 2) output += chunk; }; child.stdout.on("data", collect); child.stderr.on("data", collect); - const timer = setTimeout(() => child.kill("SIGKILL"), timeout); + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; killGroup(); }, timeout); child.on("error", (error) => { clearTimeout(timer); done({ code: -1, output: output + error.message }); }); - child.on("close", (code, killed) => { + child.on("close", (code) => { clearTimeout(timer); - done({ code: killed === "SIGKILL" ? "timeout" : code, output }); + signal?.removeEventListener("abort", killGroup); + done({ code: timedOut ? "timeout" : code, output }); }); }); } -- 2.51.2