diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..5db4ec55f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +.build/ +dist/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..6720b5d9f8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2026 Aesthetic Computer + +All rights reserved. + +This software and its source code are proprietary and confidential. No right +to use, copy, modify, distribute, sublicense, or create derivative works is +granted without prior written permission from Aesthetic Computer. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..178e4cdf48 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Aesthetic Code + +A local control plane for coding agents. + +```sh +./install.sh +ac +``` + +`ac` and `aesthetic` start the preferred agent in the current directory. + +```sh +ac ~/project +ac local +ac claude +ac codex ~/project -- resume --last +aesthetic model qwen2.5-coder:7b +aesthetic use local +aesthetic doctor +``` + +Aesthetic Code has no account, telemetry, hosted control plane, or cloud sync. +Configuration, session state, memory, and fleet coordination stay on the +developer's machines. + +`ac local` uses a locally installed Ollama model through the Claude Code agent +loopback API, disables Claude telemetry and feedback traffic, disables its web +tools, and rejects Ollama cloud model identifiers. `ac claude` and `ac codex` +are explicit provider modes; their inference leaves the machine under the +chosen provider's terms. Aesthetic Code does not resell model usage. + +When `codex-slab` is installed, provider-mode Codex sessions use it +automatically so they remain visible to prompt rocks and the fleet ledger. + +This repository is proprietary. See `LICENSE`. + +On Fish installations with existing `ac` or `aesthetic` functions, the +installer preserves them as `ac-repo` and `aesthetic-platform` before assigning +the short names to Aesthetic Code. + +The enforceable product boundary is recorded in +[`docs/local-contract.md`](docs/local-contract.md). diff --git a/bin/aesthetic b/bin/aesthetic new file mode 100755 index 0000000000..bd0ff484b9 --- /dev/null +++ b/bin/aesthetic @@ -0,0 +1,299 @@ +#!/usr/bin/env bash + +set -u + +VERSION="0.1.0" +CONFIG_DIR="${AESTHETIC_CODE_CONFIG_DIR:-${HOME}/.config/aesthetic-code}" +CONFIG_FILE="${CONFIG_DIR}/config" + +usage() { + cat <<'EOF' +Usage: aesthetic [local|claude|codex] [directory] [-- agent arguments] + ac [local|claude|codex] [directory] [-- agent arguments] + aesthetic use + aesthetic model + aesthetic doctor + +With no arguments, Aesthetic Code starts the preferred agent in the current +directory. Set AESTHETIC_CODE_AGENT to override the saved preference. +EOF +} + +fail() { + printf 'aesthetic: %s\n' "$*" >&2 + exit 1 +} + +valid_agent() { + [[ "$1" == "local" || "$1" == "claude" || "$1" == "codex" ]] +} + +read_saved_agent() { + [[ -f "$CONFIG_FILE" ]] || return 0 + sed -nE 's/^[[:space:]]*default_agent[[:space:]]*=[[:space:]]*"?(local|claude|codex)"?[[:space:]]*$/\1/p' "$CONFIG_FILE" | head -n 1 +} + +read_saved_model() { + [[ -f "$CONFIG_FILE" ]] || return 0 + sed -nE 's/^[[:space:]]*local_model[[:space:]]*=[[:space:]]*"([^"[:space:]]+)"[[:space:]]*$/\1/p' "$CONFIG_FILE" | head -n 1 +} + +write_config() { + local agent="$1" + local model="$2" + local temporary + mkdir -p "$CONFIG_DIR" + temporary="${CONFIG_FILE}.$$" + umask 077 + printf 'default_agent = "%s"\n' "$agent" > "$temporary" + if [[ -n "$model" ]]; then + printf 'local_model = "%s"\n' "$model" >> "$temporary" + fi + mv "$temporary" "$CONFIG_FILE" +} + +write_saved_agent() { + local agent="$1" + valid_agent "$agent" || fail "agent must be local, claude, or codex" + write_config "$agent" "$(read_saved_model)" + printf 'Aesthetic Code will use %s by default.\n' "$agent" +} + +write_saved_model() { + local model="$1" + local agent + [[ "$model" =~ ^[A-Za-z0-9._/:+-]+$ ]] || fail "invalid Ollama model name" + [[ "$model" != *":cloud"* && "$model" != *"-cloud"* ]] || fail "local mode refuses cloud models" + agent="$(read_saved_agent)" + write_config "${agent:-local}" "$model" + printf 'Aesthetic Code local model: %s\n' "$model" +} + +has_command() { + command -v "$1" >/dev/null 2>&1 +} + +resolve_default_agent() { + local requested="${AESTHETIC_CODE_AGENT:-}" + local saved="" + + if [[ -n "$requested" ]]; then + valid_agent "$requested" || fail "AESTHETIC_CODE_AGENT must be local, claude, or codex" + printf '%s\n' "$requested" + return + fi + + saved="$(read_saved_agent)" + if [[ -n "$saved" ]]; then + printf '%s\n' "$saved" + elif has_command codex || has_command codex-slab; then + printf 'codex\n' + elif has_command claude; then + printf 'claude\n' + else + fail "install Ollama, Claude Code, or Codex first" + fi +} + +doctor() { + local saved saved_model local_models + saved="$(read_saved_agent)" + saved_model="${AESTHETIC_CODE_LOCAL_MODEL:-$(read_saved_model)}" + printf 'Aesthetic Code %s\n' "$VERSION" + printf 'config: %s\n' "$CONFIG_FILE" + printf 'default: %s\n' "${AESTHETIC_CODE_AGENT:-${saved:-automatic}}" + printf 'control plane: local\n' + printf 'telemetry: off\n' + + if has_command ollama; then + local_models="$(ollama list 2>/dev/null | awk 'NR > 1 && $1 !~ /:cloud/ && $1 !~ /-cloud/ { count++ } END { print count + 0 }')" + printf 'local inference: %s (%s local model%s)\n' "$(command -v ollama)" "$local_models" "$([[ "$local_models" == "1" ]] && printf '' || printf 's')" + if [[ -n "$saved_model" ]]; then + printf 'local model: %s\n' "$saved_model" + fi + else + printf 'local inference: Ollama not installed\n' + fi + + if has_command claude; then + printf 'claude: %s\n' "$(command -v claude)" + else + printf 'claude: not installed\n' + fi + + if has_command codex-slab; then + printf 'codex: %s (tracked)\n' "$(command -v codex-slab)" + elif has_command codex; then + printf 'codex: %s\n' "$(command -v codex)" + else + printf 'codex: not installed\n' + fi + + if [[ -d "${HOME}/Applications/SlabMenubar.app" ]]; then + printf 'prompt rocks: installed\n' + else + printf 'prompt rocks: unavailable\n' + fi +} + +quote_command() { + local item + for item in "$@"; do + printf '%q ' "$item" + done + printf '\n' +} + +agent="" +target_directory="" +agent_arguments=() + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" || "${1:-}" == "help" ]]; then + usage + exit 0 +fi + +if [[ "${1:-}" == "--version" || "${1:-}" == "-V" || "${1:-}" == "version" ]]; then + printf 'Aesthetic Code %s\n' "$VERSION" + exit 0 +fi + +if [[ "${1:-}" == "doctor" ]]; then + doctor + exit 0 +fi + +if [[ "${1:-}" == "use" ]]; then + [[ $# -eq 2 ]] || fail "usage: aesthetic use " + write_saved_agent "$2" + exit 0 +fi + +if [[ "${1:-}" == "model" ]]; then + [[ $# -eq 2 ]] || fail "usage: aesthetic model " + write_saved_model "$2" + exit 0 +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + local|claude|codex) + if [[ -z "$agent" && -z "$target_directory" && ${#agent_arguments[@]} -eq 0 ]]; then + agent="$1" + else + agent_arguments+=("$1") + fi + shift + ;; + --agent) + [[ $# -ge 2 ]] || fail "--agent requires local, claude, or codex" + valid_agent "$2" || fail "agent must be local, claude, or codex" + agent="$2" + shift 2 + ;; + --claude) + agent="claude" + shift + ;; + --codex) + agent="codex" + shift + ;; + --local) + agent="local" + shift + ;; + --) + shift + while [[ $# -gt 0 ]]; do + agent_arguments+=("$1") + shift + done + ;; + here) + if [[ -z "$target_directory" ]]; then + target_directory="$PWD" + else + agent_arguments+=("$1") + fi + shift + ;; + *) + if [[ -z "$target_directory" && -d "$1" ]]; then + target_directory="$(cd "$1" && pwd -P)" + else + agent_arguments+=("$1") + fi + shift + ;; + esac +done + +agent="${agent:-$(resolve_default_agent)}" +target_directory="${target_directory:-$PWD}" +[[ -d "$target_directory" ]] || fail "directory does not exist: $target_directory" + +launcher="" +local_model="" +launch_command=() +case "$agent" in + codex) + if has_command codex-slab; then + launcher="$(command -v codex-slab)" + elif has_command codex; then + launcher="$(command -v codex)" + else + fail "Codex is not installed" + fi + ;; + claude) + has_command claude || fail "Claude Code is not installed" + launcher="$(command -v claude)" + ;; + local) + has_command ollama || fail "local mode requires Ollama: https://ollama.com/download" + has_command claude || fail "local mode currently requires the Claude Code CLI as its agent loop" + local_model="${AESTHETIC_CODE_LOCAL_MODEL:-$(read_saved_model)}" + if [[ -z "$local_model" ]]; then + local_model="$(ollama list 2>/dev/null | awk 'NR > 1 && $1 !~ /:cloud/ && $1 !~ /-cloud/ { print $1; exit }')" + fi + [[ -n "$local_model" ]] || fail "no local Ollama model is installed; pull one, then run: aesthetic model " + [[ "$local_model" != *":cloud"* && "$local_model" != *"-cloud"* ]] || fail "local mode refuses cloud models" + ollama show "$local_model" >/dev/null 2>&1 || fail "local Ollama model is unavailable: $local_model" + launcher="$(command -v claude)" + ;; +esac + +cd "$target_directory" || fail "cannot enter directory: $target_directory" +export AESTHETIC_CODE=1 +export AESTHETIC_CODE_VERSION="$VERSION" +export AESTHETIC_CODE_NETWORK_MODE="$([[ "$agent" == "local" ]] && printf 'local-only' || printf 'provider')" + +launch_command+=("$launcher") +if [[ "$agent" == "local" ]]; then + export ANTHROPIC_AUTH_TOKEN="ollama" + export ANTHROPIC_BASE_URL="http://127.0.0.1:11434" + export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 + export DISABLE_TELEMETRY=1 + export DISABLE_ERROR_REPORTING=1 + export DISABLE_FEEDBACK_COMMAND=1 + export CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 + launch_command+=("--model" "$local_model" "--no-chrome" "--disallowedTools" "WebFetch,WebSearch") +fi +if (( ${#agent_arguments[@]} )); then + launch_command+=("${agent_arguments[@]}") +fi + +if [[ "${AESTHETIC_CODE_DRY_RUN:-0}" == "1" ]]; then + printf 'agent=%s\n' "$agent" + printf 'directory=%s\n' "$PWD" + printf 'command=' + quote_command "${launch_command[@]}" + exit 0 +fi + +if [[ -t 1 ]]; then + printf '\033[2maesthetic code · %s · %s\033[0m\n' "$agent" "${PWD##*/}" +fi + +exec "${launch_command[@]}" diff --git a/docs/local-contract.md b/docs/local-contract.md new file mode 100644 index 0000000000..8a504f889c --- /dev/null +++ b/docs/local-contract.md @@ -0,0 +1,27 @@ +# Local contract + +Aesthetic Code requires no Aesthetic Code server. + +- No runtime account, telemetry, analytics, cloud sync, or hosted control plane. +- Configuration, session records, memory, and credentials remain on machines + controlled by the user. +- Peer discovery and control use an explicitly configured LAN or private + tailnet. They never create a public listener. +- Licenses are signed files verified offline. The application does not phone + home to remain usable. +- Updates are user-initiated. An update check may be enabled separately without + transmitting workspace or session data. + +## Inference modes + +`local` routes inference to an Ollama server on `127.0.0.1`, rejects known +Ollama cloud model names, and disables the agent loop's telemetry, feedback, +browser integration, WebFetch, and WebSearch surfaces. + +`claude` and `codex` are provider modes. The interface must label them as +provider-backed before a prompt is sent. Provider terms govern that traffic. + +The local mode does not yet impose an operating-system network sandbox on shell +commands run by the agent. A command the user approves can still access the +network. Strict offline enforcement is required before the product may claim +that arbitrary agent tools are network-isolated. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000000..7dd1c1e355 --- /dev/null +++ b/install.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN_DIR="${AESTHETIC_CODE_BIN_DIR:-${HOME}/.local/bin}" +CONFIG_DIR="${AESTHETIC_CODE_CONFIG_DIR:-${HOME}/.config/aesthetic-code}" +FISH_CONFIG="${XDG_CONFIG_HOME:-${HOME}/.config}/fish/config.fish" +ZSH_PROFILE="${ZDOTDIR:-${HOME}}/.zprofile" + +mkdir -p "$BIN_DIR" "$CONFIG_DIR" +chmod +x "$PROJECT_DIR/bin/aesthetic" +ln -sfn "$PROJECT_DIR/bin/aesthetic" "$BIN_DIR/aesthetic" +ln -sfn "$PROJECT_DIR/bin/aesthetic" "$BIN_DIR/ac" + +if [[ ! -f "$CONFIG_DIR/config" ]]; then + if command -v ollama >/dev/null 2>&1 \ + && ollama list 2>/dev/null | awk 'NR > 1 && $1 !~ /:cloud/ && $1 !~ /-cloud/ { found=1 } END { exit !found }'; then + default_agent="local" + elif command -v codex >/dev/null 2>&1 || command -v codex-slab >/dev/null 2>&1; then + default_agent="codex" + else + default_agent="claude" + fi + umask 077 + printf 'default_agent = "%s"\n' "$default_agent" > "$CONFIG_DIR/config" +fi + +if [[ -f "$FISH_CONFIG" ]] && ! grep -Fq '# aesthetic-code:start' "$FISH_CONFIG"; then + { + printf '\n# aesthetic-code:start\n' + printf 'if test -f %q\n' "$PROJECT_DIR/shell/aesthetic-code.fish" + printf ' source %q\n' "$PROJECT_DIR/shell/aesthetic-code.fish" + printf 'end\n' + printf '# aesthetic-code:end\n' + } >> "$FISH_CONFIG" +fi + +if [[ -f "$ZSH_PROFILE" ]] && ! grep -Fq '# aesthetic-code:path:start' "$ZSH_PROFILE"; then + { + printf '\n# aesthetic-code:path:start\n' + printf 'export PATH="$HOME/.local/bin:$PATH"\n' + printf '# aesthetic-code:path:end\n' + } >> "$ZSH_PROFILE" +fi + +case ":${PATH}:" in + *":${BIN_DIR}:"*) ;; + *) printf 'Add %s to PATH to use the commands.\n' "$BIN_DIR" >&2 ;; +esac + +printf 'Installed Aesthetic Code:\n' +printf ' %s\n' "$BIN_DIR/ac" +printf ' %s\n' "$BIN_DIR/aesthetic" diff --git a/shell/aesthetic-code.fish b/shell/aesthetic-code.fish new file mode 100644 index 0000000000..d53e282907 --- /dev/null +++ b/shell/aesthetic-code.fish @@ -0,0 +1,20 @@ +# Aesthetic Code command integration for Fish. +# Preserve older Aesthetic Computer helpers under explicit names before the +# product claims `ac` and `aesthetic`. +if functions -q ac; and not functions -q ac-repo + functions --copy ac ac-repo +end + +if functions -q aesthetic; and not functions -q aesthetic-platform + functions --copy aesthetic aesthetic-platform +end + +functions --erase ac aesthetic + +function ac --description 'Launch Aesthetic Code' + command $HOME/.local/bin/aesthetic $argv +end + +function aesthetic --description 'Launch Aesthetic Code' + command $HOME/.local/bin/aesthetic $argv +end diff --git a/test/cli.sh b/test/cli.sh new file mode 100755 index 0000000000..adb6e1f3a2 --- /dev/null +++ b/test/cli.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CLI="$PROJECT_DIR/bin/aesthetic" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/aesthetic-code-test.XXXXXX")" +STUB_BIN="$TEST_ROOT/bin" +CONFIG_DIR="$TEST_ROOT/config" +WORK_DIR="$TEST_ROOT/work space" + +cleanup() { + case "$TEST_ROOT" in + "${TMPDIR:-/tmp}"/aesthetic-code-test.*|/private/tmp/aesthetic-code-test.*) + rm -rf "$TEST_ROOT" + ;; + esac +} +trap cleanup EXIT + +mkdir -p "$STUB_BIN" "$CONFIG_DIR" "$WORK_DIR" +WORK_DIR="$(cd "$WORK_DIR" && pwd -P)" + +stub_agent() { + local name="$1" + cat > "$STUB_BIN/$name" <<'EOF' +#!/usr/bin/env bash +printf 'stub=%s\n' "${0##*/}" +printf 'directory=%s\n' "$PWD" +printf 'aesthetic_code=%s\n' "${AESTHETIC_CODE:-}" +printf 'network_mode=%s\n' "${AESTHETIC_CODE_NETWORK_MODE:-}" +printf 'arguments=' +printf '<%s>' "$@" +printf '\n' +EOF + chmod +x "$STUB_BIN/$name" +} + +stub_agent codex-slab +stub_agent codex +stub_agent claude +cat > "$STUB_BIN/ollama" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "list" ]]; then + printf 'NAME ID SIZE MODIFIED\n' + printf 'qwen2.5-coder:7b abc 4GB now\n' + exit 0 +fi +if [[ "${1:-}" == "show" ]]; then + [[ "${2:-}" == "qwen2.5-coder:7b" ]] && exit 0 + exit 1 +fi +printf 'stub=ollama\n' +printf 'directory=%s\n' "$PWD" +printf 'aesthetic_code=%s\n' "${AESTHETIC_CODE:-}" +printf 'network_mode=%s\n' "${AESTHETIC_CODE_NETWORK_MODE:-}" +printf 'arguments=' +printf '<%s>' "$@" +printf '\n' +EOF +chmod +x "$STUB_BIN/ollama" + +run_cli() { + PATH="$STUB_BIN:/usr/bin:/bin" \ + AESTHETIC_CODE_CONFIG_DIR="$CONFIG_DIR" \ + "$CLI" "$@" +} + +assert_contains() { + local output="$1" + local expected="$2" + [[ "$output" == *"$expected"* ]] || { + printf 'Expected output to contain: %s\nActual output:\n%s\n' "$expected" "$output" >&2 + exit 1 + } +} + +output="$(run_cli --version)" +assert_contains "$output" 'Aesthetic Code 0.1.0' + +output="$(run_cli codex "$WORK_DIR" -- resume --last)" +assert_contains "$output" 'stub=codex-slab' +assert_contains "$output" "directory=$WORK_DIR" +assert_contains "$output" 'aesthetic_code=1' +assert_contains "$output" 'arguments=<--last>' + +output="$(run_cli claude here -- --model sonnet)" +assert_contains "$output" 'stub=claude' +assert_contains "$output" 'arguments=<--model>' + +run_cli model qwen2.5-coder:7b >/dev/null +output="$(run_cli local "$WORK_DIR" -- --verbose)" +assert_contains "$output" 'stub=claude' +assert_contains "$output" 'network_mode=local-only' +assert_contains "$output" 'arguments=<--model><--no-chrome><--disallowedTools><--verbose>' + +if run_cli model qwen3-coder:cloud >/dev/null 2>&1; then + printf 'Expected a cloud model in local mode to fail.\n' >&2 + exit 1 +fi + +run_cli use claude >/dev/null +output="$(run_cli "$WORK_DIR")" +assert_contains "$output" 'stub=claude' + +if run_cli use unknown >/dev/null 2>&1; then + printf 'Expected an invalid agent to fail.\n' >&2 + exit 1 +fi + +printf 'CLI tests passed.\n' -- 2.51.2 From b69d1b1e5efe831a174dfb0ff3a304eb2bcb61f8 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 8 Sep 2026 18:29:11 -0400 Subject: [PATCH 02/18] Build Aesthetic Code TUI --- README.md | 37 ++-- bin/aesthetic | 304 +++++-------------------------- docs/local-contract.md | 23 +-- install.sh | 13 -- package.json | 9 + shell/aesthetic-code.fish | 4 +- src/app-server.mjs | 154 ++++++++++++++++ src/render.mjs | 121 +++++++++++++ src/tui.mjs | 367 ++++++++++++++++++++++++++++++++++++++ test/app-server.test.mjs | 33 ++++ test/cli.sh | 87 ++------- test/fake-app-server.mjs | 51 ++++++ test/render.test.mjs | 60 +++++++ 13 files changed, 891 insertions(+), 372 deletions(-) create mode 100644 package.json create mode 100644 src/app-server.mjs create mode 100644 src/render.mjs create mode 100755 src/tui.mjs create mode 100644 test/app-server.test.mjs create mode 100644 test/fake-app-server.mjs create mode 100644 test/render.test.mjs diff --git a/README.md b/README.md index 178e4cdf48..1ac43e6521 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,37 @@ # Aesthetic Code -A local control plane for coding agents. +A coding interface for the terminal. ```sh ./install.sh ac ``` -`ac` and `aesthetic` start the preferred agent in the current directory. +`ac` and `aesthetic` open the same full-screen interface in the current +directory. Pass a directory to open another workspace: ```sh ac ~/project -ac local -ac claude -ac codex ~/project -- resume --last -aesthetic model qwen2.5-coder:7b -aesthetic use local -aesthetic doctor ``` -Aesthetic Code has no account, telemetry, hosted control plane, or cloud sync. -Configuration, session state, memory, and fleet coordination stay on the -developer's machines. +The interface owns the conversation, streaming, tool activity, interruption, +and approvals. It does not launch a managed provider client. The current engine +bridge uses `codex app-server`; its inference is remote and the TUI labels that +boundary before a prompt is sent. A native local-inference engine can replace +the bridge without changing the interface. -`ac local` uses a locally installed Ollama model through the Claude Code agent -loopback API, disables Claude telemetry and feedback traffic, disables its web -tools, and rejects Ollama cloud model identifiers. `ac claude` and `ac codex` -are explicit provider modes; their inference leaves the machine under the -chosen provider's terms. Aesthetic Code does not resell model usage. +Inside the TUI: `/new`, `/clear`, `/help`, `/quit`. Press `ctrl-c` to interrupt +a running turn or exit while idle. -When `codex-slab` is installed, provider-mode Codex sessions use it -automatically so they remain visible to prompt rocks and the fleet ledger. +```sh +aesthetic doctor +npm test +``` This repository is proprietary. See `LICENSE`. On Fish installations with existing `ac` or `aesthetic` functions, the -installer preserves them as `ac-repo` and `aesthetic-platform` before assigning -the short names to Aesthetic Code. +installer preserves them as `ac-repo` and `aesthetic-platform`. -The enforceable product boundary is recorded in +The product boundary is recorded in [`docs/local-contract.md`](docs/local-contract.md). diff --git a/bin/aesthetic b/bin/aesthetic index bd0ff484b9..2cc8c6d431 100755 --- a/bin/aesthetic +++ b/bin/aesthetic @@ -1,21 +1,27 @@ #!/usr/bin/env bash -set -u - -VERSION="0.1.0" -CONFIG_DIR="${AESTHETIC_CODE_CONFIG_DIR:-${HOME}/.config/aesthetic-code}" -CONFIG_FILE="${CONFIG_DIR}/config" +set -euo pipefail + +VERSION="0.2.0" +script_path="${BASH_SOURCE[0]}" +while [[ -L "$script_path" ]]; do + script_directory="$(cd "$(dirname "$script_path")" && pwd -P)" + link_target="$(readlink "$script_path")" + if [[ "$link_target" == /* ]]; then + script_path="$link_target" + else + script_path="$script_directory/$link_target" + fi +done +PROJECT_DIR="$(cd "$(dirname "$script_path")/.." && pwd -P)" usage() { cat <<'EOF' -Usage: aesthetic [local|claude|codex] [directory] [-- agent arguments] - ac [local|claude|codex] [directory] [-- agent arguments] - aesthetic use - aesthetic model +Usage: ac [directory] + aesthetic [directory] aesthetic doctor -With no arguments, Aesthetic Code starts the preferred agent in the current -directory. Set AESTHETIC_CODE_AGENT to override the saved preference. +Open the Aesthetic Code terminal interface in a workspace. EOF } @@ -24,276 +30,62 @@ fail() { exit 1 } -valid_agent() { - [[ "$1" == "local" || "$1" == "claude" || "$1" == "codex" ]] -} - -read_saved_agent() { - [[ -f "$CONFIG_FILE" ]] || return 0 - sed -nE 's/^[[:space:]]*default_agent[[:space:]]*=[[:space:]]*"?(local|claude|codex)"?[[:space:]]*$/\1/p' "$CONFIG_FILE" | head -n 1 -} - -read_saved_model() { - [[ -f "$CONFIG_FILE" ]] || return 0 - sed -nE 's/^[[:space:]]*local_model[[:space:]]*=[[:space:]]*"([^"[:space:]]+)"[[:space:]]*$/\1/p' "$CONFIG_FILE" | head -n 1 -} - -write_config() { - local agent="$1" - local model="$2" - local temporary - mkdir -p "$CONFIG_DIR" - temporary="${CONFIG_FILE}.$$" - umask 077 - printf 'default_agent = "%s"\n' "$agent" > "$temporary" - if [[ -n "$model" ]]; then - printf 'local_model = "%s"\n' "$model" >> "$temporary" - fi - mv "$temporary" "$CONFIG_FILE" -} - -write_saved_agent() { - local agent="$1" - valid_agent "$agent" || fail "agent must be local, claude, or codex" - write_config "$agent" "$(read_saved_model)" - printf 'Aesthetic Code will use %s by default.\n' "$agent" -} - -write_saved_model() { - local model="$1" - local agent - [[ "$model" =~ ^[A-Za-z0-9._/:+-]+$ ]] || fail "invalid Ollama model name" - [[ "$model" != *":cloud"* && "$model" != *"-cloud"* ]] || fail "local mode refuses cloud models" - agent="$(read_saved_agent)" - write_config "${agent:-local}" "$model" - printf 'Aesthetic Code local model: %s\n' "$model" -} - has_command() { command -v "$1" >/dev/null 2>&1 } -resolve_default_agent() { - local requested="${AESTHETIC_CODE_AGENT:-}" - local saved="" - - if [[ -n "$requested" ]]; then - valid_agent "$requested" || fail "AESTHETIC_CODE_AGENT must be local, claude, or codex" - printf '%s\n' "$requested" - return - fi - - saved="$(read_saved_agent)" - if [[ -n "$saved" ]]; then - printf '%s\n' "$saved" - elif has_command codex || has_command codex-slab; then - printf 'codex\n' - elif has_command claude; then - printf 'claude\n' - else - fail "install Ollama, Claude Code, or Codex first" - fi -} - doctor() { - local saved saved_model local_models - saved="$(read_saved_agent)" - saved_model="${AESTHETIC_CODE_LOCAL_MODEL:-$(read_saved_model)}" printf 'Aesthetic Code %s\n' "$VERSION" - printf 'config: %s\n' "$CONFIG_FILE" - printf 'default: %s\n' "${AESTHETIC_CODE_AGENT:-${saved:-automatic}}" + printf 'interface: %s\n' "$PROJECT_DIR/src/tui.mjs" printf 'control plane: local\n' printf 'telemetry: off\n' - if has_command ollama; then - local_models="$(ollama list 2>/dev/null | awk 'NR > 1 && $1 !~ /:cloud/ && $1 !~ /-cloud/ { count++ } END { print count + 0 }')" - printf 'local inference: %s (%s local model%s)\n' "$(command -v ollama)" "$local_models" "$([[ "$local_models" == "1" ]] && printf '' || printf 's')" - if [[ -n "$saved_model" ]]; then - printf 'local model: %s\n' "$saved_model" - fi + if has_command node; then + printf 'node: %s\n' "$(command -v node)" else - printf 'local inference: Ollama not installed\n' + printf 'node: not installed\n' fi - if has_command claude; then - printf 'claude: %s\n' "$(command -v claude)" + if has_command codex; then + printf 'engine bridge: ready\n' else - printf 'claude: not installed\n' + printf 'engine bridge: unavailable (install Codex)\n' fi - - if has_command codex-slab; then - printf 'codex: %s (tracked)\n' "$(command -v codex-slab)" - elif has_command codex; then - printf 'codex: %s\n' "$(command -v codex)" - else - printf 'codex: not installed\n' - fi - - if [[ -d "${HOME}/Applications/SlabMenubar.app" ]]; then - printf 'prompt rocks: installed\n' - else - printf 'prompt rocks: unavailable\n' - fi -} - -quote_command() { - local item - for item in "$@"; do - printf '%q ' "$item" - done - printf '\n' } -agent="" -target_directory="" -agent_arguments=() - -if [[ "${1:-}" == "--help" || "${1:-}" == "-h" || "${1:-}" == "help" ]]; then - usage - exit 0 -fi - -if [[ "${1:-}" == "--version" || "${1:-}" == "-V" || "${1:-}" == "version" ]]; then - printf 'Aesthetic Code %s\n' "$VERSION" - exit 0 -fi - -if [[ "${1:-}" == "doctor" ]]; then - doctor - exit 0 -fi - -if [[ "${1:-}" == "use" ]]; then - [[ $# -eq 2 ]] || fail "usage: aesthetic use " - write_saved_agent "$2" - exit 0 -fi - -if [[ "${1:-}" == "model" ]]; then - [[ $# -eq 2 ]] || fail "usage: aesthetic model " - write_saved_model "$2" - exit 0 -fi - -while [[ $# -gt 0 ]]; do - case "$1" in - local|claude|codex) - if [[ -z "$agent" && -z "$target_directory" && ${#agent_arguments[@]} -eq 0 ]]; then - agent="$1" - else - agent_arguments+=("$1") - fi - shift - ;; - --agent) - [[ $# -ge 2 ]] || fail "--agent requires local, claude, or codex" - valid_agent "$2" || fail "agent must be local, claude, or codex" - agent="$2" - shift 2 - ;; - --claude) - agent="claude" - shift - ;; - --codex) - agent="codex" - shift - ;; - --local) - agent="local" - shift - ;; - --) - shift - while [[ $# -gt 0 ]]; do - agent_arguments+=("$1") - shift - done - ;; - here) - if [[ -z "$target_directory" ]]; then - target_directory="$PWD" - else - agent_arguments+=("$1") - fi - shift - ;; - *) - if [[ -z "$target_directory" && -d "$1" ]]; then - target_directory="$(cd "$1" && pwd -P)" - else - agent_arguments+=("$1") - fi - shift - ;; - esac -done - -agent="${agent:-$(resolve_default_agent)}" -target_directory="${target_directory:-$PWD}" -[[ -d "$target_directory" ]] || fail "directory does not exist: $target_directory" - -launcher="" -local_model="" -launch_command=() -case "$agent" in - codex) - if has_command codex-slab; then - launcher="$(command -v codex-slab)" - elif has_command codex; then - launcher="$(command -v codex)" - else - fail "Codex is not installed" - fi +case "${1:-}" in + --help|-h|help) + usage + exit 0 ;; - claude) - has_command claude || fail "Claude Code is not installed" - launcher="$(command -v claude)" + --version|-V|version) + printf 'Aesthetic Code %s\n' "$VERSION" + exit 0 ;; - local) - has_command ollama || fail "local mode requires Ollama: https://ollama.com/download" - has_command claude || fail "local mode currently requires the Claude Code CLI as its agent loop" - local_model="${AESTHETIC_CODE_LOCAL_MODEL:-$(read_saved_model)}" - if [[ -z "$local_model" ]]; then - local_model="$(ollama list 2>/dev/null | awk 'NR > 1 && $1 !~ /:cloud/ && $1 !~ /-cloud/ { print $1; exit }')" - fi - [[ -n "$local_model" ]] || fail "no local Ollama model is installed; pull one, then run: aesthetic model " - [[ "$local_model" != *":cloud"* && "$local_model" != *"-cloud"* ]] || fail "local mode refuses cloud models" - ollama show "$local_model" >/dev/null 2>&1 || fail "local Ollama model is unavailable: $local_model" - launcher="$(command -v claude)" + doctor) + [[ $# -eq 1 ]] || fail "doctor takes no arguments" + doctor + exit 0 ;; esac -cd "$target_directory" || fail "cannot enter directory: $target_directory" -export AESTHETIC_CODE=1 -export AESTHETIC_CODE_VERSION="$VERSION" -export AESTHETIC_CODE_NETWORK_MODE="$([[ "$agent" == "local" ]] && printf 'local-only' || printf 'provider')" +[[ $# -le 1 ]] || fail "expected one workspace directory; run aesthetic --help" -launch_command+=("$launcher") -if [[ "$agent" == "local" ]]; then - export ANTHROPIC_AUTH_TOKEN="ollama" - export ANTHROPIC_BASE_URL="http://127.0.0.1:11434" - export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 - export DISABLE_TELEMETRY=1 - export DISABLE_ERROR_REPORTING=1 - export DISABLE_FEEDBACK_COMMAND=1 - export CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 - launch_command+=("--model" "$local_model" "--no-chrome" "--disallowedTools" "WebFetch,WebSearch") -fi -if (( ${#agent_arguments[@]} )); then - launch_command+=("${agent_arguments[@]}") -fi +target_directory="${1:-$PWD}" +[[ -d "$target_directory" ]] || fail "directory does not exist: $target_directory" +target_directory="$(cd "$target_directory" && pwd -P)" + +has_command node || fail "Node.js is required" +has_command codex || fail "the Aesthetic Code engine bridge is not installed" if [[ "${AESTHETIC_CODE_DRY_RUN:-0}" == "1" ]]; then - printf 'agent=%s\n' "$agent" - printf 'directory=%s\n' "$PWD" - printf 'command=' - quote_command "${launch_command[@]}" + printf 'interface=aesthetic-code\n' + printf 'directory=%s\n' "$target_directory" exit 0 fi -if [[ -t 1 ]]; then - printf '\033[2maesthetic code · %s · %s\033[0m\n' "$agent" "${PWD##*/}" -fi +[[ -t 0 && -t 1 ]] || fail "an interactive terminal is required" -exec "${launch_command[@]}" +export AESTHETIC_CODE=1 +export AESTHETIC_CODE_VERSION="$VERSION" +exec node "$PROJECT_DIR/src/tui.mjs" "$target_directory" diff --git a/docs/local-contract.md b/docs/local-contract.md index 8a504f889c..ccff591464 100644 --- a/docs/local-contract.md +++ b/docs/local-contract.md @@ -12,16 +12,19 @@ Aesthetic Code requires no Aesthetic Code server. - Updates are user-initiated. An update check may be enabled separately without transmitting workspace or session data. -## Inference modes +## Inference boundary -`local` routes inference to an Ollama server on `127.0.0.1`, rejects known -Ollama cloud model names, and disables the agent loop's telemetry, feedback, -browser integration, WebFetch, and WebSearch surfaces. +The terminal interface is always Aesthetic Code. Engines are internal bridges, +not alternate client interfaces or command shortcuts. -`claude` and `codex` are provider modes. The interface must label them as -provider-backed before a prompt is sent. Provider terms govern that traffic. +The current bridge uses Codex app-server and remote inference. The interface +labels this before a prompt is sent. Provider terms govern that traffic. -The local mode does not yet impose an operating-system network sandbox on shell -commands run by the agent. A command the user approves can still access the -network. Strict offline enforcement is required before the product may claim -that arbitrary agent tools are network-isolated. +A future local bridge must route inference only to a loopback or explicitly +configured private endpoint, reject known cloud model names, and disable +telemetry, feedback, browser integration, WebFetch, and WebSearch surfaces. + +Local inference will not by itself impose an operating-system network sandbox +on shell commands run by the agent. A command the user approves can still +access the network. Strict offline enforcement is required before the product +may claim that arbitrary agent tools are network-isolated. diff --git a/install.sh b/install.sh index 7dd1c1e355..bcd7d9c17d 100755 --- a/install.sh +++ b/install.sh @@ -13,19 +13,6 @@ chmod +x "$PROJECT_DIR/bin/aesthetic" ln -sfn "$PROJECT_DIR/bin/aesthetic" "$BIN_DIR/aesthetic" ln -sfn "$PROJECT_DIR/bin/aesthetic" "$BIN_DIR/ac" -if [[ ! -f "$CONFIG_DIR/config" ]]; then - if command -v ollama >/dev/null 2>&1 \ - && ollama list 2>/dev/null | awk 'NR > 1 && $1 !~ /:cloud/ && $1 !~ /-cloud/ { found=1 } END { exit !found }'; then - default_agent="local" - elif command -v codex >/dev/null 2>&1 || command -v codex-slab >/dev/null 2>&1; then - default_agent="codex" - else - default_agent="claude" - fi - umask 077 - printf 'default_agent = "%s"\n' "$default_agent" > "$CONFIG_DIR/config" -fi - if [[ -f "$FISH_CONFIG" ]] && ! grep -Fq '# aesthetic-code:start' "$FISH_CONFIG"; then { printf '\n# aesthetic-code:start\n' diff --git a/package.json b/package.json new file mode 100644 index 0000000000..27be1ec87a --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "aesthetic-code", + "version": "0.2.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test/*.test.mjs && bash test/cli.sh" + } +} diff --git a/shell/aesthetic-code.fish b/shell/aesthetic-code.fish index d53e282907..ffeb2eeced 100644 --- a/shell/aesthetic-code.fish +++ b/shell/aesthetic-code.fish @@ -11,10 +11,10 @@ end functions --erase ac aesthetic -function ac --description 'Launch Aesthetic Code' +function ac --description 'Open Aesthetic Code' command $HOME/.local/bin/aesthetic $argv end -function aesthetic --description 'Launch Aesthetic Code' +function aesthetic --description 'Open Aesthetic Code' command $HOME/.local/bin/aesthetic $argv end diff --git a/src/app-server.mjs b/src/app-server.mjs new file mode 100644 index 0000000000..438a901f91 --- /dev/null +++ b/src/app-server.mjs @@ -0,0 +1,154 @@ +import { spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { createInterface } from "node:readline"; + +const VERSION = "0.2.0"; + +export class AppServer extends EventEmitter { + constructor({ cwd, command = "codex", args = ["app-server", "--stdio"] }) { + super(); + this.cwd = cwd; + this.command = command; + this.args = args; + this.child = null; + this.nextId = 1; + this.pending = new Map(); + this.threadId = null; + this.turnId = null; + this.closed = false; + } + + async connect() { + this.child = spawn(this.command, this.args, { + cwd: this.cwd, + env: { + ...process.env, + AESTHETIC_CODE: "1", + AESTHETIC_CODE_VERSION: VERSION, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + this.child.once("error", (error) => this.#failAll(error)); + this.child.once("exit", (code, signal) => { + const suffix = signal ? ` (${signal})` : code === null ? "" : ` (${code})`; + this.#failAll(new Error(`engine bridge closed${suffix}`)); + this.emit("exit", { code, signal }); + }); + + createInterface({ input: this.child.stdout }).on("line", (line) => { + if (!line.trim()) return; + try { + this.#receive(JSON.parse(line)); + } catch (error) { + this.emit("protocolError", new Error(`invalid engine message: ${error.message}`)); + } + }); + + createInterface({ input: this.child.stderr }).on("line", (line) => { + if (line.trim()) this.emit("log", line.trim()); + }); + + await this.request("initialize", { + clientInfo: { + name: "aesthetic_code", + title: "Aesthetic Code", + version: VERSION, + }, + capabilities: { experimentalApi: true }, + }); + this.notify("initialized", {}); + return this.newThread(); + } + + async newThread() { + const result = await this.request("thread/start", { + cwd: this.cwd, + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandbox: "workspace-write", + ephemeral: false, + sessionStartSource: this.threadId ? "clear" : "startup", + }); + this.threadId = result.thread.id; + this.turnId = null; + return result; + } + + async startTurn(text) { + if (!this.threadId) throw new Error("thread is not ready"); + const result = await this.request("turn/start", { + threadId: this.threadId, + input: [{ type: "text", text }], + }); + this.turnId = result.turn.id; + return result; + } + + async interrupt() { + if (!this.threadId || !this.turnId) return; + await this.request("turn/interrupt", { + threadId: this.threadId, + turnId: this.turnId, + }); + } + + request(method, params) { + const id = this.nextId++; + this.#send({ method, id, params }); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + }); + } + + notify(method, params) { + this.#send({ method, params }); + } + + respond(id, result) { + this.#send({ id, result }); + } + + reject(id, code, message) { + this.#send({ id, error: { code, message } }); + } + + close() { + if (this.closed) return; + this.closed = true; + this.child?.kill("SIGTERM"); + } + + #send(message) { + if (!this.child?.stdin.writable) throw new Error("engine bridge is not writable"); + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } + + #receive(message) { + if (Object.hasOwn(message, "id") && !message.method) { + const waiter = this.pending.get(message.id); + if (!waiter) return; + this.pending.delete(message.id); + if (message.error) { + waiter.reject(new Error(message.error.message || "engine request failed")); + } else { + waiter.resolve(message.result); + } + return; + } + + if (Object.hasOwn(message, "id") && message.method) { + this.emit("request", message); + return; + } + + if (message.method) this.emit("notification", message); + } + + #failAll(error) { + if (this.closed && this.pending.size === 0) return; + for (const { reject } of this.pending.values()) reject(error); + this.pending.clear(); + this.emit("fatal", error); + } +} diff --git a/src/render.mjs b/src/render.mjs new file mode 100644 index 0000000000..b42fdaf7a6 --- /dev/null +++ b/src/render.mjs @@ -0,0 +1,121 @@ +const ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; +const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; + +export const color = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + pink: "\x1b[38;5;205m", + cyan: "\x1b[38;5;80m", + green: "\x1b[38;5;84m", + yellow: "\x1b[38;5;221m", + red: "\x1b[38;5;203m", + inverse: "\x1b[7m", +}; + +export function cleanText(value) { + return String(value ?? "") + .replace(ESCAPE, "") + .replace(CONTROLS, "") + .replace(/\r/g, "") + .replace(/\t/g, " "); +} + +export function textWidth(value) { + return Array.from(cleanText(value)).length; +} + +export function clipText(value, width) { + const characters = Array.from(cleanText(value)); + if (characters.length <= width) return characters.join(""); + if (width <= 1) return "…".slice(0, width); + return `${characters.slice(0, width - 1).join("")}…`; +} + +export function wrapText(value, width) { + const safeWidth = Math.max(1, width); + const output = []; + const paragraphs = cleanText(value).split("\n"); + + for (const paragraph of paragraphs) { + if (!paragraph) { + output.push(""); + continue; + } + let remaining = paragraph; + while (textWidth(remaining) > safeWidth) { + const characters = Array.from(remaining); + const window = characters.slice(0, safeWidth + 1).join(""); + const breakAt = window.lastIndexOf(" "); + const count = breakAt > Math.floor(safeWidth * 0.45) ? breakAt : safeWidth; + output.push(characters.slice(0, count).join("").trimEnd()); + remaining = characters.slice(count).join("").trimStart(); + } + output.push(remaining); + } + return output; +} + +function paint(enabled, tone, value) { + return enabled ? `${color[tone]}${value}${color.reset}` : value; +} + +function pad(value, width) { + return value + " ".repeat(Math.max(0, width - textWidth(value))); +} + +function entryLines(entry, width, useColor) { + const styles = { + user: ["YOU", "pink"], + assistant: ["AC", "cyan"], + command: ["RUN", "yellow"], + change: ["EDIT", "green"], + notice: ["·", "dim"], + error: ["!", "red"], + }; + const [label, tone] = styles[entry.kind] || styles.notice; + const prefix = `${label.padEnd(4)} `; + const continuation = " ".repeat(5); + return wrapText(entry.text, Math.max(1, width - 5)).map((line, index) => + `${paint(useColor, tone, index === 0 ? prefix : continuation)}${line}`, + ); +} + +export function renderFrame(state, columns = 80, rows = 24, useColor = true) { + const width = Math.max(32, columns); + const height = Math.max(10, rows); + const workspace = clipText(state.workspace || "workspace", Math.max(8, width - 34)); + const mode = state.mode === "local" ? "LOCAL" : "REMOTE"; + const right = `${mode} · ${String(state.status || "ready").toUpperCase()}`; + const gap = " ".repeat(Math.max(1, width - 2 - textWidth("AESTHETIC CODE") - textWidth(right))); + const header = ` ${paint(useColor, "bold", "AESTHETIC CODE")}${gap}${paint(useColor, state.mode === "local" ? "green" : "yellow", right)} `; + const pathLine = paint(useColor, "dim", ` ${clipText(workspace, width - 2)}`); + + const transcriptRows = height - 5; + const transcript = state.entries.flatMap((entry) => entryLines(entry, width - 2, useColor)); + const visible = transcript.slice(Math.max(0, transcript.length - transcriptRows)); + while (visible.length < transcriptRows) visible.unshift(""); + + let prompt; + if (state.approval) { + const subject = clipText(state.approval.subject || "requested action", Math.max(4, width - 31)); + prompt = `${paint(useColor, "yellow", "ALLOW")} ${subject} ${paint(useColor, "bold", "y")} once ${paint(useColor, "bold", "a")} session ${paint(useColor, "bold", "n")} deny`; + } 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 - 3); + const start = Math.max(0, cursor - room + 1); + const visibleInput = input.slice(start, start + room); + const visibleCursor = cursor - start; + const before = visibleInput.slice(0, visibleCursor).join(""); + const underCursor = visibleInput[visibleCursor] || " "; + const after = visibleInput.slice(visibleCursor + 1).join(""); + const cursorCell = useColor ? paint(true, "inverse", underCursor) : underCursor; + prompt = `${paint(useColor, "pink", "›")} ${start > 0 ? "‹" : ""}${before}${cursorCell}${after}`; + } + + const rule = paint(useColor, "dim", "─".repeat(width)); + const help = paint(useColor, "dim", state.busy ? " ctrl-c interrupt" : " /help · ctrl-c quit"); + const lines = [header, pathLine, ...visible.map((line) => ` ${line}`), rule, prompt, pad(help, width)]; + return lines.slice(0, height).map((line) => pad(line, width)).join("\n"); +} diff --git a/src/tui.mjs b/src/tui.mjs new file mode 100755 index 0000000000..0a4564556f --- /dev/null +++ b/src/tui.mjs @@ -0,0 +1,367 @@ +#!/usr/bin/env node + +import path from "node:path"; +import process from "node:process"; +import { AppServer } from "./app-server.mjs"; +import { cleanText, renderFrame } from "./render.mjs"; + +const cwd = path.resolve(process.argv[2] || process.cwd()); +const state = { + workspace: cwd, + mode: "remote", + status: "starting", + busy: false, + input: "", + cursor: 0, + history: [], + historyIndex: 0, + approval: null, + entries: [ + { + id: "privacy", + kind: "notice", + text: "REMOTE INFERENCE · prompt content may leave this machine", + }, + ], +}; + +const engine = new AppServer({ cwd }); +let drawing = false; +let closing = false; +let streamedMessageId = null; +let pasteBuffer = null; + +function addEntry(kind, text, id = `entry-${Date.now()}-${Math.random()}`) { + state.entries.push({ id, kind, text: cleanText(text) }); + if (state.entries.length > 300) state.entries.splice(0, state.entries.length - 300); + return id; +} + +function updateEntry(id, kind, text) { + const entry = state.entries.find((candidate) => candidate.id === id); + if (entry) { + entry.kind = kind; + entry.text = cleanText(text); + } else { + addEntry(kind, text, id); + } +} + +function redraw() { + if (closing || drawing) return; + drawing = true; + const frame = renderFrame(state, process.stdout.columns, process.stdout.rows, process.env.NO_COLOR !== "1"); + process.stdout.write(`\x1b[H\x1b[2J${frame}`); + drawing = false; +} + +function finish(code = 0) { + if (closing) return; + closing = true; + engine.close(); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\x1b[?2004l\x1b[?25h\x1b[?1049l"); + process.exitCode = code; +} + +function errorText(error) { + return cleanText(error?.message || error || "unknown error"); +} + +function itemSummary(item) { + if (!item) return null; + if (item.type === "commandExecution") return { kind: "command", text: item.command }; + if (item.type === "fileChange") { + const files = (item.changes || []).map((change) => change.path).join(", "); + return { kind: "change", text: files || "workspace files" }; + } + if (item.type === "mcpToolCall") return { kind: "command", text: `${item.server} · ${item.tool}` }; + if (item.type === "dynamicToolCall") return { kind: "command", text: item.tool }; + return null; +} + +function handleNotification({ method, params = {} }) { + switch (method) { + case "turn/started": + state.busy = true; + state.status = "working"; + engine.turnId = params.turn?.id || engine.turnId; + break; + case "item/agentMessage/delta": + if (!streamedMessageId || streamedMessageId !== params.itemId) { + streamedMessageId = params.itemId; + addEntry("assistant", "", params.itemId); + } + { + const entry = state.entries.find((candidate) => candidate.id === params.itemId); + if (entry) entry.text += cleanText(params.delta); + } + break; + case "item/started": { + const summary = itemSummary(params.item); + if (summary) updateEntry(params.item.id, summary.kind, summary.text); + break; + } + case "item/completed": { + const item = params.item; + if (item?.type === "agentMessage") updateEntry(item.id, "assistant", item.text); + const summary = itemSummary(item); + if (summary) { + let suffix = ""; + if (item.type === "commandExecution") { + suffix = item.exitCode === null || item.exitCode === 0 ? " · done" : ` · exit ${item.exitCode}`; + } else if (item.status) { + suffix = ` · ${item.status}`; + } + updateEntry(item.id, summary.kind, `${summary.text}${suffix}`); + } + break; + } + case "item/commandExecution/outputDelta": { + const entry = state.entries.find((candidate) => candidate.id === params.itemId); + if (entry && params.delta) { + const lastLine = cleanText(params.delta).trim().split("\n").at(-1); + if (lastLine) entry.text = `${entry.text.split("\n")[0]}\n${lastLine}`; + } + break; + } + case "turn/completed": { + state.busy = false; + state.status = params.turn?.status === "failed" ? "failed" : "ready"; + engine.turnId = null; + streamedMessageId = null; + const failure = params.turn?.error; + if (failure) addEntry("error", failure.message || JSON.stringify(failure)); + break; + } + case "warning": + addEntry("notice", params.message || "Engine warning"); + break; + case "error": + addEntry("error", params.error?.message || "Engine error"); + if (!params.willRetry) state.status = "failed"; + break; + } + redraw(); +} + +function approvalSubject(method, params) { + if (method === "item/commandExecution/requestApproval") { + return params.command || params.reason || "command"; + } + if (method === "item/fileChange/requestApproval") { + return params.reason || params.grantRoot || "file change"; + } + return method; +} + +function handleRequest(request) { + if ( + request.method === "item/commandExecution/requestApproval" || + request.method === "item/fileChange/requestApproval" + ) { + state.approval = { + id: request.id, + method: request.method, + subject: approvalSubject(request.method, request.params || {}), + }; + state.status = "approval"; + redraw(); + return; + } + engine.reject(request.id, -32601, `Aesthetic Code does not support ${request.method} yet`); +} + +function answerApproval(character) { + const approval = state.approval; + if (!approval) return false; + const key = character.toLowerCase(); + const decision = + key === "y" + ? "accept" + : key === "a" + ? "acceptForSession" + : key === "n" + ? "decline" + : character === "\u0003" + ? "cancel" + : null; + if (!decision) return true; + engine.respond(approval.id, { decision }); + const result = decision === "decline" ? "Denied" : decision === "cancel" ? "Cancelled" : "Allowed"; + addEntry("notice", `${result}: ${approval.subject}`); + state.approval = null; + state.status = state.busy ? "working" : "ready"; + redraw(); + return true; +} + +async function submitInput() { + const text = state.input.trim(); + state.input = ""; + state.cursor = 0; + state.historyIndex = state.history.length; + if (!text) return redraw(); + + if (text.startsWith("/")) { + if (text === "/quit" || text === "/exit") return finish(); + if (text === "/clear") { + state.entries = []; + return redraw(); + } + if (text === "/help") { + addEntry("notice", "/new · /clear · /quit ctrl-c interrupts a running turn"); + return redraw(); + } + if (text === "/new") { + if (state.busy) { + addEntry("error", "Interrupt the current turn before starting a new thread."); + } else { + state.status = "starting"; + redraw(); + try { + await engine.newThread(); + state.entries = [{ kind: "notice", text: "New thread", id: `thread-${Date.now()}` }]; + state.status = "ready"; + } catch (error) { + addEntry("error", errorText(error)); + state.status = "failed"; + } + } + return redraw(); + } + addEntry("error", `Unknown command: ${text}`); + return redraw(); + } + + if (state.busy) { + addEntry("error", "A turn is already running. Press ctrl-c to interrupt it."); + return redraw(); + } + + state.history.push(text); + state.historyIndex = state.history.length; + addEntry("user", text); + state.busy = true; + state.status = "working"; + redraw(); + try { + await engine.startTurn(text); + } catch (error) { + state.busy = false; + state.status = "failed"; + addEntry("error", errorText(error)); + redraw(); + } +} + +function replaceInput(value) { + state.input = value; + state.cursor = Array.from(value).length; +} + +function insertText(value) { + const characters = Array.from(state.input); + const inserted = Array.from(cleanText(value.replace(/\x1b\[200~|\x1b\[201~/g, ""))); + characters.splice(state.cursor, 0, ...inserted); + state.input = characters.join(""); + state.cursor += inserted.length; +} + +function handleKey(input) { + if (answerApproval(input)) return; + + if (input === "\u0003") { + if (state.busy) { + state.status = "interrupting"; + redraw(); + engine.interrupt().catch((error) => addEntry("error", errorText(error))); + } else { + finish(); + } + return; + } + if (input === "\u0004" && !state.input) return finish(); + if (input === "\u000c") return redraw(); + if (input === "\u0001") state.cursor = 0; + else if (input === "\u0005") state.cursor = Array.from(state.input).length; + else if (input === "\u0015") replaceInput(""); + else if (input === "\u000b") state.input = Array.from(state.input).slice(0, state.cursor).join(""); + else if (input === "\r" || input === "\n") return void submitInput(); + else if (input === "\x1b[A") { + if (state.historyIndex > 0) replaceInput(state.history[--state.historyIndex]); + } else if (input === "\x1b[B") { + if (state.historyIndex < state.history.length - 1) replaceInput(state.history[++state.historyIndex]); + else { + state.historyIndex = state.history.length; + replaceInput(""); + } + } 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) { + const characters = Array.from(state.input); + characters.splice(--state.cursor, 1); + state.input = characters.join(""); + } + } else if (input === "\x1b[3~") { + const characters = Array.from(state.input); + characters.splice(state.cursor, 1); + state.input = characters.join(""); + } else if (!input.startsWith("\x1b")) { + insertText(input); + } + redraw(); +} + +function handleKeys(buffer) { + const tokens = buffer.toString("utf8").match(/\x1b\[[0-9;]*[~A-Za-z]|./gsu) || []; + for (const token of tokens) { + if (token === "\x1b[200~") { + pasteBuffer = ""; + } else if (token === "\x1b[201~") { + if (pasteBuffer !== null) insertText(pasteBuffer); + pasteBuffer = null; + redraw(); + } else if (pasteBuffer !== null) { + pasteBuffer += token; + } else { + handleKey(token); + } + } +} + +process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[?2004h"); +process.stdin.setRawMode(true); +process.stdin.resume(); +process.stdin.on("data", handleKeys); +process.stdout.on("resize", redraw); +process.on("SIGTERM", () => finish(143)); +process.on("SIGHUP", () => finish(129)); + +engine.on("notification", handleNotification); +engine.on("request", handleRequest); +engine.on("protocolError", (error) => { + addEntry("error", errorText(error)); + redraw(); +}); +engine.on("fatal", (error) => { + if (!closing) { + state.status = "offline"; + addEntry("error", errorText(error)); + redraw(); + } +}); + +redraw(); +try { + const connection = await engine.connect(); + state.status = "ready"; + if (connection?.model) addEntry("notice", `Ready · ${connection.model}`); + redraw(); +} catch (error) { + state.status = "offline"; + addEntry("error", errorText(error)); + redraw(); +} diff --git a/test/app-server.test.mjs b/test/app-server.test.mjs new file mode 100644 index 0000000000..6d8aea5d2b --- /dev/null +++ b/test/app-server.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { AppServer } from "../src/app-server.mjs"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); + +test("drives a thread without opening a managed client", async () => { + const engine = new AppServer({ + cwd: directory, + command: process.execPath, + args: [path.join(directory, "fake-app-server.mjs")], + }); + const deltas = []; + const completed = new Promise((resolve) => { + engine.on("notification", (message) => { + if (message.method === "item/agentMessage/delta") deltas.push(message.params.delta); + if (message.method === "turn/completed") resolve(message.params.turn.status); + }); + }); + engine.on("request", (message) => engine.respond(message.id, { decision: "accept" })); + + const connection = await engine.connect(); + assert.equal(connection.thread.id, "thread-1"); + assert.equal(connection.model, "test-model"); + + const turn = await engine.startTurn("run the tests"); + assert.equal(turn.turn.id, "turn-1"); + assert.equal(await completed, "completed"); + assert.equal(deltas.join(""), "Tests pass."); + engine.close(); +}); diff --git a/test/cli.sh b/test/cli.sh index adb6e1f3a2..db1cf771fc 100755 --- a/test/cli.sh +++ b/test/cli.sh @@ -5,9 +5,8 @@ set -euo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CLI="$PROJECT_DIR/bin/aesthetic" TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/aesthetic-code-test.XXXXXX")" -STUB_BIN="$TEST_ROOT/bin" -CONFIG_DIR="$TEST_ROOT/config" WORK_DIR="$TEST_ROOT/work space" +TEST_BIN="$TEST_ROOT/bin" cleanup() { case "$TEST_ROOT" in @@ -18,53 +17,9 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$STUB_BIN" "$CONFIG_DIR" "$WORK_DIR" +mkdir -p "$WORK_DIR" "$TEST_BIN" WORK_DIR="$(cd "$WORK_DIR" && pwd -P)" - -stub_agent() { - local name="$1" - cat > "$STUB_BIN/$name" <<'EOF' -#!/usr/bin/env bash -printf 'stub=%s\n' "${0##*/}" -printf 'directory=%s\n' "$PWD" -printf 'aesthetic_code=%s\n' "${AESTHETIC_CODE:-}" -printf 'network_mode=%s\n' "${AESTHETIC_CODE_NETWORK_MODE:-}" -printf 'arguments=' -printf '<%s>' "$@" -printf '\n' -EOF - chmod +x "$STUB_BIN/$name" -} - -stub_agent codex-slab -stub_agent codex -stub_agent claude -cat > "$STUB_BIN/ollama" <<'EOF' -#!/usr/bin/env bash -if [[ "${1:-}" == "list" ]]; then - printf 'NAME ID SIZE MODIFIED\n' - printf 'qwen2.5-coder:7b abc 4GB now\n' - exit 0 -fi -if [[ "${1:-}" == "show" ]]; then - [[ "${2:-}" == "qwen2.5-coder:7b" ]] && exit 0 - exit 1 -fi -printf 'stub=ollama\n' -printf 'directory=%s\n' "$PWD" -printf 'aesthetic_code=%s\n' "${AESTHETIC_CODE:-}" -printf 'network_mode=%s\n' "${AESTHETIC_CODE_NETWORK_MODE:-}" -printf 'arguments=' -printf '<%s>' "$@" -printf '\n' -EOF -chmod +x "$STUB_BIN/ollama" - -run_cli() { - PATH="$STUB_BIN:/usr/bin:/bin" \ - AESTHETIC_CODE_CONFIG_DIR="$CONFIG_DIR" \ - "$CLI" "$@" -} +ln -s "$CLI" "$TEST_BIN/ac" assert_contains() { local output="$1" @@ -75,36 +30,28 @@ assert_contains() { } } -output="$(run_cli --version)" -assert_contains "$output" 'Aesthetic Code 0.1.0' +output="$($CLI --version)" +assert_contains "$output" 'Aesthetic Code 0.2.0' -output="$(run_cli codex "$WORK_DIR" -- resume --last)" -assert_contains "$output" 'stub=codex-slab' +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" +assert_contains "$output" 'interface=aesthetic-code' assert_contains "$output" "directory=$WORK_DIR" -assert_contains "$output" 'aesthetic_code=1' -assert_contains "$output" 'arguments=<--last>' -output="$(run_cli claude here -- --model sonnet)" -assert_contains "$output" 'stub=claude' -assert_contains "$output" 'arguments=<--model>' +output="$(AESTHETIC_CODE_DRY_RUN=1 "$TEST_BIN/ac" "$WORK_DIR")" +assert_contains "$output" 'interface=aesthetic-code' +assert_contains "$output" "directory=$WORK_DIR" -run_cli model qwen2.5-coder:7b >/dev/null -output="$(run_cli local "$WORK_DIR" -- --verbose)" -assert_contains "$output" 'stub=claude' -assert_contains "$output" 'network_mode=local-only' -assert_contains "$output" 'arguments=<--model><--no-chrome><--disallowedTools><--verbose>' +output="$($CLI doctor)" +assert_contains "$output" 'control plane: local' +assert_contains "$output" 'telemetry: off' -if run_cli model qwen3-coder:cloud >/dev/null 2>&1; then - printf 'Expected a cloud model in local mode to fail.\n' >&2 +if AESTHETIC_CODE_DRY_RUN=1 "$CLI" claude >/dev/null 2>&1; then + printf 'Expected the removed provider shortcut to fail.\n' >&2 exit 1 fi -run_cli use claude >/dev/null -output="$(run_cli "$WORK_DIR")" -assert_contains "$output" 'stub=claude' - -if run_cli use unknown >/dev/null 2>&1; then - printf 'Expected an invalid agent to fail.\n' >&2 +if AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR" extra >/dev/null 2>&1; then + printf 'Expected extra launcher arguments to fail.\n' >&2 exit 1 fi diff --git a/test/fake-app-server.mjs b/test/fake-app-server.mjs new file mode 100644 index 0000000000..1fd45ce0ef --- /dev/null +++ b/test/fake-app-server.mjs @@ -0,0 +1,51 @@ +import { createInterface } from "node:readline"; + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +createInterface({ input: process.stdin }).on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + send({ id: message.id, result: { userAgent: "fake" } }); + } else if (message.method === "thread/start") { + send({ + id: message.id, + result: { + thread: { id: "thread-1" }, + model: "test-model", + modelProvider: "test", + cwd: message.params.cwd, + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandbox: { type: "workspaceWrite" }, + }, + }); + } else if (message.method === "turn/start") { + send({ id: message.id, result: { turn: { id: "turn-1", status: "inProgress", items: [] } } }); + send({ + method: "turn/started", + params: { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress", items: [] } }, + }); + send({ + method: "item/commandExecution/requestApproval", + id: 900, + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "item-1", + command: "npm test", + startedAtMs: Date.now(), + }, + }); + } else if (message.id === 900 && message.result?.decision === "accept") { + send({ + method: "item/agentMessage/delta", + params: { threadId: "thread-1", turnId: "turn-1", itemId: "answer-1", delta: "Tests pass." }, + }); + send({ + method: "turn/completed", + params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } }, + }); + } +}); diff --git a/test/render.test.mjs b/test/render.test.mjs new file mode 100644 index 0000000000..eab4a2db87 --- /dev/null +++ b/test/render.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { cleanText, renderFrame, wrapText } from "../src/render.mjs"; + +test("cleans terminal control sequences", () => { + assert.equal(cleanText("safe\x1b[2J\x00 text"), "safe text"); +}); + +test("wraps content to the available width", () => { + const lines = wrapText("aesthetic code owns the terminal interface", 12); + assert.ok(lines.length > 1); + assert.ok(lines.every((line) => Array.from(line).length <= 12)); +}); + +test("renders one branded interface with privacy state and prompt", () => { + const frame = renderFrame( + { + workspace: "/Users/jas/project", + mode: "remote", + status: "ready", + busy: false, + input: "make the tests pass", + entries: [ + { kind: "notice", text: "REMOTE INFERENCE · prompt content may leave this machine" }, + { kind: "user", text: "inspect this repository" }, + { kind: "assistant", text: "I found the failing test." }, + ], + }, + 80, + 20, + false, + ); + assert.match(frame, /AESTHETIC CODE/); + assert.match(frame, /REMOTE · READY/); + assert.match(frame, /YOU inspect this repository/); + assert.match(frame, /AC I found the failing test/); + assert.match(frame, /› make the tests pass/); + assert.equal(frame.split("\n").length, 20); +}); + +test("renders approvals inside the interface", () => { + const frame = renderFrame( + { + workspace: "/project", + mode: "remote", + status: "approval", + busy: true, + input: "", + entries: [], + approval: { subject: "npm test" }, + }, + 60, + 14, + false, + ); + assert.match(frame, /ALLOW npm test/); + assert.match(frame, /y once/); + assert.match(frame, /a session/); + assert.match(frame, /n deny/); +}); -- 2.51.2 From b84747e22171eb6e66c536739977d027d1304d06 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 8 Sep 2026 21:34:37 -0400 Subject: [PATCH 03/18] Register Aesthetic Code with prox --- README.md | 3 + bin/aesthetic | 45 ++++++++- package.json | 2 +- src/app-server.mjs | 28 +++++- src/slab-session.mjs | 188 +++++++++++++++++++++++++++++++++++++ src/tui.mjs | 64 ++++++++++++- test/app-server.test.mjs | 14 +++ test/cli.sh | 6 +- test/fake-app-server.mjs | 4 +- test/slab-session.test.mjs | 53 +++++++++++ 10 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 src/slab-session.mjs create mode 100644 test/slab-session.test.mjs diff --git a/README.md b/README.md index 1ac43e6521..10584497b5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ bridge uses `codex app-server`; its inference is remote and the TUI labels that boundary before a prompt is sent. A native local-inference engine can replace the bridge without changing the interface. +Each live TUI publishes its own Slab marker, so the menubar and prox ledger can +name, focus, wake, close, and track it as `aesthetic-code`. + Inside the TUI: `/new`, `/clear`, `/help`, `/quit`. Press `ctrl-c` to interrupt a running turn or exit while idle. diff --git a/bin/aesthetic b/bin/aesthetic index 2cc8c6d431..0474cb7737 100755 --- a/bin/aesthetic +++ b/bin/aesthetic @@ -2,7 +2,7 @@ set -euo pipefail -VERSION="0.2.0" +VERSION="0.2.1" script_path="${BASH_SOURCE[0]}" while [[ -L "$script_path" ]]; do script_directory="$(cd "$(dirname "$script_path")" && pwd -P)" @@ -69,9 +69,41 @@ case "${1:-}" in ;; esac -[[ $# -le 1 ]] || fail "expected one workspace directory; run aesthetic --help" +target_directory="" +resume_thread="" +initial_prompt="" +while [[ $# -gt 0 ]]; do + case "$1" in + --resume) + [[ $# -ge 2 ]] || fail "--resume requires a thread id" + [[ "$2" =~ ^[0-9A-Fa-f-]{36}$ ]] || fail "invalid thread id" + resume_thread="$2" + shift 2 + ;; + --prompt) + [[ $# -ge 2 ]] || fail "--prompt requires text" + [[ ${#2} -le 4000 ]] || fail "prompt exceeds 4000 characters" + initial_prompt="$2" + shift 2 + ;; + --) + shift + [[ $# -le 1 ]] || fail "expected one workspace directory" + if [[ $# -eq 1 ]]; then target_directory="$1"; fi + shift $(( $# > 0 ? 1 : 0 )) + ;; + -*) + fail "unknown option: $1" + ;; + *) + [[ -z "$target_directory" ]] || fail "expected one workspace directory; run aesthetic --help" + target_directory="$1" + shift + ;; + esac +done -target_directory="${1:-$PWD}" +target_directory="${target_directory:-$PWD}" [[ -d "$target_directory" ]] || fail "directory does not exist: $target_directory" target_directory="$(cd "$target_directory" && pwd -P)" @@ -81,6 +113,8 @@ has_command codex || fail "the Aesthetic Code engine bridge is not installed" if [[ "${AESTHETIC_CODE_DRY_RUN:-0}" == "1" ]]; then printf 'interface=aesthetic-code\n' printf 'directory=%s\n' "$target_directory" + printf 'resume=%s\n' "$([[ -n "$resume_thread" ]] && printf yes || printf no)" + printf 'initial_prompt=%s\n' "$([[ -n "$initial_prompt" ]] && printf yes || printf no)" exit 0 fi @@ -88,4 +122,7 @@ fi export AESTHETIC_CODE=1 export AESTHETIC_CODE_VERSION="$VERSION" -exec node "$PROJECT_DIR/src/tui.mjs" "$target_directory" +arguments=("--cwd" "$target_directory") +if [[ -n "$resume_thread" ]]; then arguments+=("--resume" "$resume_thread"); fi +if [[ -n "$initial_prompt" ]]; then arguments+=("--prompt" "$initial_prompt"); fi +exec node "$PROJECT_DIR/src/tui.mjs" "${arguments[@]}" diff --git a/package.json b/package.json index 27be1ec87a..a02f157de7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aesthetic-code", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "scripts": { diff --git a/src/app-server.mjs b/src/app-server.mjs index 438a901f91..465444417f 100644 --- a/src/app-server.mjs +++ b/src/app-server.mjs @@ -2,14 +2,22 @@ import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { createInterface } from "node:readline"; -const VERSION = "0.2.0"; +const VERSION = "0.2.1"; export class AppServer extends EventEmitter { - constructor({ cwd, command = "codex", args = ["app-server", "--stdio"] }) { + constructor({ + cwd, + resumeThreadId = "", + command = "codex", + args = ["app-server", "--stdio"], + environment = {}, + }) { super(); this.cwd = cwd; this.command = command; this.args = args; + this.environment = environment; + this.resumeThreadId = resumeThreadId; this.child = null; this.nextId = 1; this.pending = new Map(); @@ -23,6 +31,7 @@ export class AppServer extends EventEmitter { cwd: this.cwd, env: { ...process.env, + ...this.environment, AESTHETIC_CODE: "1", AESTHETIC_CODE_VERSION: VERSION, }, @@ -58,7 +67,7 @@ export class AppServer extends EventEmitter { capabilities: { experimentalApi: true }, }); this.notify("initialized", {}); - return this.newThread(); + return this.resumeThreadId ? this.resumeThread(this.resumeThreadId) : this.newThread(); } async newThread() { @@ -75,6 +84,19 @@ export class AppServer extends EventEmitter { return result; } + async resumeThread(threadId) { + const result = await this.request("thread/resume", { + threadId, + cwd: this.cwd, + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandbox: "workspace-write", + }); + this.threadId = result.thread.id; + this.turnId = null; + return result; + } + async startTurn(text) { if (!this.threadId) throw new Error("thread is not ready"); const result = await this.request("turn/start", { diff --git a/src/slab-session.mjs b/src/slab-session.mjs new file mode 100644 index 0000000000..7ea66ffd82 --- /dev/null +++ b/src/slab-session.mjs @@ -0,0 +1,188 @@ +import { + appendFileSync, + mkdirSync, + renameSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +function now() { + return new Date().toISOString().replace(/\.\d+Z$/, "Z"); +} + +function terminalName(pid) { + if (process.env.SLAB_TERMINAL_TTY) return process.env.SLAB_TERMINAL_TTY.replace(/^\/dev\//, ""); + try { + return execFileSync("/bin/ps", ["-o", "tty=", "-p", String(pid)], { + encoding: "utf8", + }).trim().replace(/^\/dev\//, ""); + } catch { + return ""; + } +} + +function summary(text) { + const words = String(text || "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean); + const value = words.slice(0, 7).join(" "); + return words.length > 7 ? `${value.slice(0, 47)}…` : value; +} + +export class SlabSession { + constructor({ + cwd, + pid = process.pid, + tty = terminalName(pid), + sessionId = randomUUID(), + slabHome = process.env.SLAB_HOME || join(homedir(), ".local", "share", "slab"), + }) { + this.cwd = cwd; + this.pid = pid; + this.tty = tty; + this.sessionId = sessionId; + this.stateDir = join(slabHome, "state"); + this.active = join(this.stateDir, "active-prompts", sessionId); + this.awaiting = join(this.stateDir, "awaiting-prompts", sessionId); + this.running = join(this.stateDir, "running-tools", sessionId); + this.enabled = false; + this.heartbeat = null; + this.record = { + session_id: sessionId, + cwd, + subject: "aesthetic code", + summary: "aesthetic code", + tty, + agent_pid: pid, + agent_type: "aesthetic-code", + provider_agent_type: "codex", + provider_session_id: "", + updated: now(), + started_at: now(), + state: "blank", + }; + } + + start() { + try { + for (const name of ["active-prompts", "awaiting-prompts", "running-tools"]) { + mkdirSync(join(this.stateDir, name), { recursive: true, mode: 0o700 }); + } + this.enabled = true; + this.#write(); + } catch { + this.enabled = false; + } + return this.sessionId; + } + + connected(providerSessionId) { + this.#update({ provider_session_id: providerSessionId || "" }); + } + + working(prompt = "") { + this.#remove(this.awaiting); + this.#touch(this.running); + const clean = String(prompt || "").replace(/\s+/g, " ").trim(); + this.#update({ + state: "working", + ...(clean ? { subject: clean.slice(0, 140), summary: summary(clean) } : {}), + }); + this.#startHeartbeat(); + } + + awaitingInput(message = "aesthetic code needs input") { + this.#stopHeartbeat(); + this.#remove(this.running); + this.#writeText(this.awaiting, `${message}\n`); + this.#update({ state: "awaiting" }); + } + + resumeWork() { + this.#remove(this.awaiting); + this.#touch(this.running); + this.#update({ state: "working" }); + this.#startHeartbeat(); + } + + complete() { + this.#stopHeartbeat(); + this.#remove(this.running); + this.#writeText(this.awaiting, "turn complete\n"); + this.#update({ state: "complete" }); + } + + interrupted() { + this.#stopHeartbeat(); + this.#remove(this.awaiting); + this.#remove(this.running); + this.#update({ state: "interrupted" }); + } + + close() { + this.#stopHeartbeat(); + this.#remove(this.active); + this.#remove(this.awaiting); + this.#remove(this.running); + this.enabled = false; + } + + #startHeartbeat() { + if (this.heartbeat) return; + this.heartbeat = setInterval(() => { + this.#touch(this.running); + this.#update({ state: "working" }); + }, 5_000); + this.heartbeat.unref(); + } + + #stopHeartbeat() { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = null; + } + + #update(patch) { + if (!this.enabled) return; + Object.assign(this.record, patch, { updated: now() }); + this.#write(); + } + + #write() { + if (!this.enabled) return; + const temporary = `${this.active}.${this.pid}.tmp`; + try { + writeFileSync(temporary, `${JSON.stringify(this.record)}\n`, { mode: 0o600 }); + renameSync(temporary, this.active); + } catch { + this.#remove(temporary); + } + } + + #writeText(path, text) { + if (!this.enabled) return; + try { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, text, { mode: 0o600 }); + } catch {} + } + + #touch(path) { + if (!this.enabled) return; + try { + appendFileSync(path, "", { mode: 0o600 }); + const time = new Date(); + utimesSync(path, time, time); + } catch { + this.#writeText(path, ""); + } + } + + #remove(path) { + try { + rmSync(path, { force: true }); + } catch {} + } +} diff --git a/src/tui.mjs b/src/tui.mjs index 0a4564556f..2030a336e7 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -4,8 +4,16 @@ import path from "node:path"; import process from "node:process"; import { AppServer } from "./app-server.mjs"; import { cleanText, renderFrame } from "./render.mjs"; +import { SlabSession } from "./slab-session.mjs"; -const cwd = path.resolve(process.argv[2] || process.cwd()); +const arguments_ = process.argv.slice(2); +const option = (name) => { + const index = arguments_.indexOf(name); + return index >= 0 ? arguments_[index + 1] || "" : ""; +}; +const cwd = path.resolve(option("--cwd") || process.cwd()); +const resumeThreadId = option("--resume"); +const initialPrompt = option("--prompt"); const state = { workspace: cwd, mode: "remote", @@ -25,7 +33,17 @@ const state = { ], }; -const engine = new AppServer({ cwd }); +const slabSession = new SlabSession({ cwd }); +slabSession.start(); +const engine = new AppServer({ + cwd, + resumeThreadId, + environment: { + SLAB_PROMPT_SESSION_ID: slabSession.sessionId, + SLAB_TERMINAL_TTY: slabSession.tty, + SLAB_AGENT_TYPE: "aesthetic-code", + }, +}); let drawing = false; let closing = false; let streamedMessageId = null; @@ -58,6 +76,7 @@ function redraw() { function finish(code = 0) { if (closing) return; closing = true; + slabSession.close(); engine.close(); process.stdin.setRawMode(false); process.stdin.pause(); @@ -81,12 +100,32 @@ function itemSummary(item) { return null; } +function restoreThread(thread) { + const restored = []; + for (const turn of thread?.turns || []) { + for (const item of turn.items || []) { + if (item.type === "userMessage") { + const text = (item.content || []) + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("\n"); + if (text) restored.push({ id: item.id, kind: "user", text: cleanText(text) }); + } else if (item.type === "agentMessage" && item.text) { + restored.push({ id: item.id, kind: "assistant", text: cleanText(item.text) }); + } + } + } + state.entries.push(...restored.slice(-80)); + return restored.length; +} + function handleNotification({ method, params = {} }) { switch (method) { case "turn/started": state.busy = true; state.status = "working"; engine.turnId = params.turn?.id || engine.turnId; + slabSession.working(); break; case "item/agentMessage/delta": if (!streamedMessageId || streamedMessageId !== params.itemId) { @@ -133,6 +172,9 @@ function handleNotification({ method, params = {} }) { streamedMessageId = null; const failure = params.turn?.error; if (failure) addEntry("error", failure.message || JSON.stringify(failure)); + if (params.turn?.status === "interrupted") slabSession.interrupted(); + else if (params.turn?.status === "failed") slabSession.awaitingInput("aesthetic code turn failed"); + else slabSession.complete(); break; } case "warning": @@ -166,6 +208,11 @@ function handleRequest(request) { method: request.method, subject: approvalSubject(request.method, request.params || {}), }; + slabSession.awaitingInput( + request.method === "item/commandExecution/requestApproval" + ? "aesthetic code needs command approval" + : "aesthetic code needs file approval", + ); state.status = "approval"; redraw(); return; @@ -192,6 +239,8 @@ function answerApproval(character) { const result = decision === "decline" ? "Denied" : decision === "cancel" ? "Cancelled" : "Allowed"; addEntry("notice", `${result}: ${approval.subject}`); state.approval = null; + if (decision === "cancel") slabSession.interrupted(); + else slabSession.resumeWork(); state.status = state.busy ? "working" : "ready"; redraw(); return true; @@ -222,6 +271,7 @@ async function submitInput() { redraw(); try { await engine.newThread(); + slabSession.connected(engine.threadId); state.entries = [{ kind: "notice", text: "New thread", id: `thread-${Date.now()}` }]; state.status = "ready"; } catch (error) { @@ -243,6 +293,7 @@ async function submitInput() { state.history.push(text); state.historyIndex = state.history.length; addEntry("user", text); + slabSession.working(text); state.busy = true; state.status = "working"; redraw(); @@ -350,6 +401,7 @@ engine.on("fatal", (error) => { if (!closing) { state.status = "offline"; addEntry("error", errorText(error)); + slabSession.awaitingInput("aesthetic code engine bridge is offline"); redraw(); } }); @@ -357,9 +409,15 @@ engine.on("fatal", (error) => { redraw(); try { const connection = await engine.connect(); + slabSession.connected(connection.thread.id); state.status = "ready"; - if (connection?.model) addEntry("notice", `Ready · ${connection.model}`); + if (resumeThreadId && !restoreThread(connection.thread)) addEntry("notice", "Resumed thread"); + else if (connection?.model) addEntry("notice", `Ready · ${connection.model}`); redraw(); + if (initialPrompt) { + replaceInput(initialPrompt); + await submitInput(); + } } catch (error) { state.status = "offline"; addEntry("error", errorText(error)); diff --git a/test/app-server.test.mjs b/test/app-server.test.mjs index 6d8aea5d2b..1a030eda77 100644 --- a/test/app-server.test.mjs +++ b/test/app-server.test.mjs @@ -31,3 +31,17 @@ test("drives a thread without opening a managed client", async () => { assert.equal(deltas.join(""), "Tests pass."); engine.close(); }); + +test("resumes a provider thread behind the same interface", async () => { + const threadId = "00000000-0000-0000-0000-000000000001"; + const engine = new AppServer({ + cwd: directory, + resumeThreadId: threadId, + command: process.execPath, + args: [path.join(directory, "fake-app-server.mjs")], + }); + const connection = await engine.connect(); + assert.equal(connection.thread.id, threadId); + assert.equal(engine.threadId, threadId); + engine.close(); +}); diff --git a/test/cli.sh b/test/cli.sh index db1cf771fc..5cd6eb17af 100755 --- a/test/cli.sh +++ b/test/cli.sh @@ -31,7 +31,7 @@ assert_contains() { } output="$($CLI --version)" -assert_contains "$output" 'Aesthetic Code 0.2.0' +assert_contains "$output" 'Aesthetic Code 0.2.1' output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" assert_contains "$output" 'interface=aesthetic-code' @@ -41,6 +41,10 @@ output="$(AESTHETIC_CODE_DRY_RUN=1 "$TEST_BIN/ac" "$WORK_DIR")" assert_contains "$output" 'interface=aesthetic-code' assert_contains "$output" "directory=$WORK_DIR" +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --resume 00000000-0000-0000-0000-000000000001 --prompt continue "$WORK_DIR")" +assert_contains "$output" 'resume=yes' +assert_contains "$output" 'initial_prompt=yes' + output="$($CLI doctor)" assert_contains "$output" 'control plane: local' assert_contains "$output" 'telemetry: off' diff --git a/test/fake-app-server.mjs b/test/fake-app-server.mjs index 1fd45ce0ef..cd940fb4fc 100644 --- a/test/fake-app-server.mjs +++ b/test/fake-app-server.mjs @@ -8,11 +8,11 @@ createInterface({ input: process.stdin }).on("line", (line) => { const message = JSON.parse(line); if (message.method === "initialize") { send({ id: message.id, result: { userAgent: "fake" } }); - } else if (message.method === "thread/start") { + } else if (message.method === "thread/start" || message.method === "thread/resume") { send({ id: message.id, result: { - thread: { id: "thread-1" }, + thread: { id: message.params.threadId || "thread-1", turns: [] }, model: "test-model", modelProvider: "test", cwd: message.params.cwd, diff --git a/test/slab-session.test.mjs b/test/slab-session.test.mjs new file mode 100644 index 0000000000..0764365a95 --- /dev/null +++ b/test/slab-session.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { SlabSession } from "../src/slab-session.mjs"; + +const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); +const exists = async (path) => stat(path).then(() => true, () => false); + +test("publishes the full Slab prompt lifecycle", async (context) => { + const root = await mkdtemp(join(tmpdir(), "aesthetic-code-slab-")); + context.after(() => rm(root, { recursive: true, force: true })); + const session = new SlabSession({ + cwd: "/project", + pid: process.pid, + tty: "ttys099", + sessionId: "ac-session", + slabHome: root, + }); + const active = join(root, "state", "active-prompts", "ac-session"); + const awaiting = join(root, "state", "awaiting-prompts", "ac-session"); + const running = join(root, "state", "running-tools", "ac-session"); + + session.start(); + let marker = await readJson(active); + assert.equal(marker.agent_type, "aesthetic-code"); + assert.equal(marker.state, "blank"); + + session.connected("00000000-0000-0000-0000-000000000001"); + session.working("make this window visible to prox"); + marker = await readJson(active); + assert.equal(marker.provider_session_id, "00000000-0000-0000-0000-000000000001"); + assert.equal(marker.subject, "make this window visible to prox"); + assert.equal(marker.state, "working"); + assert.equal(await exists(running), true); + + session.awaitingInput("needs approval"); + assert.equal(await readFile(awaiting, "utf8"), "needs approval\n"); + assert.equal(await exists(running), false); + + session.resumeWork(); + assert.equal(await exists(awaiting), false); + assert.equal(await exists(running), true); + + session.complete(); + assert.equal(await readFile(awaiting, "utf8"), "turn complete\n"); + assert.equal((await readJson(active)).state, "complete"); + + session.close(); + assert.equal(await exists(active), false); + assert.equal(await exists(awaiting), false); +}); -- 2.51.2 From 0673db58d58b9b2038eb0f35abfe4c940146ce27 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 12:18:37 -0400 Subject: [PATCH 04/18] Sign in, show the handle, and publish pieces under it - src/ac-session.mjs reads and watches ~/.ac-token, refreshes it, and runs the Auth0 PKCE sign-in with a loopback callback - src/publish.mjs mirrors the web prompt's publish: presigned user-bucket grant, PUT, read-back; /publish and `aesthetic publish` - header shows @handle and the current piece; prompt.mjs purple/pink palette with a 256-color fallback - developer instructions tell the engine that disks/ files are not live - slab marker carries the handle --- README.md | 30 +++- bin/aesthetic | 16 ++- docs/local-contract.md | 9 ++ package.json | 2 +- src/ac-session.mjs | 302 +++++++++++++++++++++++++++++++++++++++ src/app-server.mjs | 6 +- src/cli.mjs | 44 ++++++ src/publish.mjs | 101 +++++++++++++ src/render.mjs | 139 ++++++++++++++---- src/slab-session.mjs | 6 + src/tui.mjs | 155 +++++++++++++++++++- test/ac-session.test.mjs | 125 ++++++++++++++++ test/cli.sh | 25 +++- test/publish.test.mjs | 80 +++++++++++ test/render.test.mjs | 22 +++ 15 files changed, 1018 insertions(+), 44 deletions(-) create mode 100644 src/ac-session.mjs create mode 100644 src/cli.mjs create mode 100644 src/publish.mjs create mode 100644 test/ac-session.test.mjs create mode 100644 test/publish.test.mjs diff --git a/README.md b/README.md index 10584497b5..bb4ad86421 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,37 @@ boundary before a prompt is sent. A native local-inference engine can replace the bridge without changing the interface. Each live TUI publishes its own Slab marker, so the menubar and prox ledger can -name, focus, wake, close, and track it as `aesthetic-code`. +name, focus, wake, close, and track it as `aesthetic-code`, including which +@handle it acts as. -Inside the TUI: `/new`, `/clear`, `/help`, `/quit`. Press `ctrl-c` to interrupt +The interface uses the Aesthetic Computer prompt's palette (purple ground, pink +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 [slug]`, +`/piece [name]`, `/new`, `/clear`, `/help`, `/quit`. Press `ctrl-c` to interrupt a running turn or exit while idle. +## Account and publishing + +Aesthetic Code reads the shared Aesthetic Computer sign-in at `~/.ac-token`, +the same file `ac-login` and the AC desktop apps use. `/login` runs the +Authorization-Code + PKCE flow in your browser with a loopback callback and +writes that file; a sign-in or sign-out anywhere in the suite updates the +header live. Only the handle is shown. + +`/publish [slug]` puts a `.mjs` or `.lisp` piece live under your handle +at `https://aesthetic.computer/@handle/slug`, exactly like the web prompt's +`publish` command: it requests a presigned upload grant in your user bucket, +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. + +```sh +aesthetic login +aesthetic whoami +aesthetic publish system/public/aesthetic.computer/disks/smiley.mjs +``` + ```sh aesthetic doctor npm test diff --git a/bin/aesthetic b/bin/aesthetic index 0474cb7737..e21c60c786 100755 --- a/bin/aesthetic +++ b/bin/aesthetic @@ -2,7 +2,7 @@ set -euo pipefail -VERSION="0.2.1" +VERSION="0.3.0" script_path="${BASH_SOURCE[0]}" while [[ -L "$script_path" ]]; do script_directory="$(cd "$(dirname "$script_path")" && pwd -P)" @@ -20,8 +20,12 @@ usage() { Usage: ac [directory] aesthetic [directory] aesthetic doctor + aesthetic login | logout | whoami + aesthetic publish [slug] -Open the Aesthetic Code terminal interface in a workspace. +Open the Aesthetic Code terminal interface in a workspace, or manage the +shared Aesthetic Computer sign-in (~/.ac-token) and publish a piece under +your @handle at https://aesthetic.computer/@handle/slug. EOF } @@ -51,6 +55,10 @@ doctor() { else printf 'engine bridge: unavailable (install Codex)\n' fi + + if has_command node; then + printf 'account: %s\n' "$(node "$PROJECT_DIR/src/cli.mjs" whoami 2>/dev/null || printf 'not signed in')" + fi } case "${1:-}" in @@ -67,6 +75,10 @@ case "${1:-}" in doctor exit 0 ;; + login|logout|whoami|publish) + has_command node || fail "Node.js is required" + exec node "$PROJECT_DIR/src/cli.mjs" "$@" + ;; esac target_directory="" diff --git a/docs/local-contract.md b/docs/local-contract.md index ccff591464..8434c2c6ec 100644 --- a/docs/local-contract.md +++ b/docs/local-contract.md @@ -12,6 +12,15 @@ Aesthetic Code requires no Aesthetic Code server. - Updates are user-initiated. An update check may be enabled separately without transmitting workspace or session data. +## Account and publishing boundary + +Signing in talks to Auth0 (`hi.aesthetic.computer`) and `aesthetic.computer` +only when the user runs `/login`, and stores the result in `~/.ac-token`, the +shared Aesthetic Computer session file. Publishing sends one piece's source +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. + ## Inference boundary The terminal interface is always Aesthetic Code. Engines are internal bridges, diff --git a/package.json b/package.json index a02f157de7..44d22922da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aesthetic-code", - "version": "0.2.1", + "version": "0.3.0", "private": true, "type": "module", "scripts": { diff --git a/src/ac-session.mjs b/src/ac-session.mjs new file mode 100644 index 0000000000..5feb25b90e --- /dev/null +++ b/src/ac-session.mjs @@ -0,0 +1,302 @@ +// ac-session.mjs — the shared Aesthetic Computer sign-in. +// +// Every AC desktop app reads one file, ~/.ac-token, minted by `ac-login` with +// Auth0 Authorization-Code + PKCE and a loopback callback. This module reads +// and watches that file, refreshes the access token, and can run the same +// sign-in flow itself so Aesthetic Code needs no other checkout. Only the +// handle is ever displayed; email and name stay in the file. +import { EventEmitter } from "node:events"; +import { createHash, randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; + +export const AUTH_DOMAIN = "hi.aesthetic.computer"; +export const CLIENT_ID = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt"; +export const CALLBACK_PORT = 44233; +export const SITE = "https://aesthetic.computer"; +// Cloudflare fronts aesthetic.computer and rejects a bare user agent (1010). +export const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/125.0 Safari/537.36 aesthetic-code"; + +const base64url = (buffer) => + buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + +const LANDING = ` +Signed in · Aesthetic Code + +

Signed in

Return to Aesthetic Code. You can close this tab.

`; + +export function openInBrowser(url) { + const command = + process.env.BROWSER || + (process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"); + try { + spawn(command, [url], { stdio: "ignore", detached: true }).unref(); + return true; + } catch { + return false; + } +} + +export class ACSession extends EventEmitter { + constructor({ + file = join(homedir(), ".ac-token"), + fetch = globalThis.fetch, + openBrowser = openInBrowser, + callbackPort = CALLBACK_PORT, + site = SITE, + authDomain = AUTH_DOMAIN, + now = Date.now, + } = {}) { + super(); + this.file = file; + this.fetch = fetch; + this.openBrowser = openBrowser; + this.callbackPort = callbackPort; + this.site = site; + this.authDomain = authDomain; + this.now = now; + this.watchers = []; + this.debounce = null; + this.signingIn = false; + } + + read() { + try { + const record = JSON.parse(readFileSync(this.file, "utf8")); + return record && typeof record === "object" ? record : null; + } catch { + return null; + } + } + + get handle() { + return String(this.read()?.user?.handle || "").replace(/^@/, ""); + } + + get signedIn() { + return Boolean(this.read()?.access_token); + } + + get state() { + const record = this.read(); + if (!record?.access_token) return "signed-out"; + return record.user?.handle ? "signed-in" : "no-handle"; + } + + label() { + const state = this.state; + if (state === "signed-in") return `@${this.handle}`; + if (state === "no-handle") return "signed in · no handle"; + return "not signed in"; + } + + // Watch the file and its directory: ac-login rewrites in place (same inode), + // logout deletes, and an atomic replace swaps the inode. Re-arm after every + // change so a stale file watcher never goes quiet. + watch() { + this.unwatch(); + const fire = () => { + clearTimeout(this.debounce); + this.debounce = setTimeout(() => { + this.watch(); + this.emit("change", this.label()); + }, 80); + this.debounce.unref?.(); + }; + try { + const directory = watch(dirname(this.file), (_event, name) => { + if (!name || name === basename(this.file)) fire(); + }); + directory.unref?.(); + this.watchers.push(directory); + } catch {} + if (existsSync(this.file)) { + try { + const file = watch(this.file, fire); + file.unref?.(); + this.watchers.push(file); + } catch {} + } + return this; + } + + unwatch() { + for (const watcher of this.watchers) { + try { + watcher.close(); + } catch {} + } + this.watchers = []; + } + + // A usable access token, refreshed a minute early through the refresh grant. + async token() { + const record = this.read(); + if (!record?.access_token) throw new Error("not signed in — run /login"); + const stale = record.expires_at && this.now() > record.expires_at - 60_000; + if (!stale) return record.access_token; + if (!record.refresh_token) throw new Error("session expired — run /login"); + const response = await this.fetch(`https://${this.authDomain}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: record.refresh_token, + }), + }); + if (!response.ok) throw new Error(`session refresh failed (HTTP ${response.status}) — run /login`); + const next = await response.json(); + record.access_token = next.access_token; + if (next.refresh_token) record.refresh_token = next.refresh_token; + if (next.id_token) record.id_token = next.id_token; + record.expires_at = this.now() + (next.expires_in || 3600) * 1000; + this.#write(record); + return record.access_token; + } + + // Authorization-Code + PKCE against Auth0 with a loopback callback. Resolves + // to the @handle (or null when the account has none yet). + async login({ forcePrompt = false, timeoutMs = 5 * 60_000, onUrl } = {}) { + if (this.signingIn) throw new Error("a sign-in is already waiting on the browser"); + this.signingIn = true; + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + const state = randomBytes(16).toString("hex"); + const server = createServer(); + try { + const port = await new Promise((resolve, reject) => { + server.once("error", (error) => + reject( + error.code === "EADDRINUSE" + ? new Error(`port ${this.callbackPort} is in use — close any running ac-login and retry`) + : error, + ), + ); + server.listen(this.callbackPort, "127.0.0.1", () => resolve(server.address().port)); + }); + const redirectUri = `http://localhost:${port}/callback`; + const authUrl = new URL(`https://${this.authDomain}/authorize`); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("client_id", CLIENT_ID); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", "openid profile email offline_access"); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + if (forcePrompt) authUrl.searchParams.set("prompt", "login"); + + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("sign-in timed out")), timeoutMs); + timer.unref?.(); + const settle = (fn, value) => { + clearTimeout(timer); + fn(value); + }; + server.on("request", (request, response) => { + const url = new URL(request.url, redirectUri); + if (url.pathname !== "/callback") { + response.writeHead(404); + response.end("Not found"); + return; + } + const failure = url.searchParams.get("error"); + if (failure) { + response.writeHead(400, { "content-type": "text/plain" }); + response.end("Sign-in failed. Return to Aesthetic Code."); + settle(reject, new Error(url.searchParams.get("error_description") || failure)); + return; + } + if (url.searchParams.get("state") !== state) { + response.writeHead(400, { "content-type": "text/plain" }); + response.end("State mismatch. Return to Aesthetic Code and retry."); + settle(reject, new Error("sign-in state mismatch — retry /login")); + return; + } + const grant = url.searchParams.get("code"); + if (!grant) { + response.writeHead(400, { "content-type": "text/plain" }); + response.end("Missing authorization code."); + settle(reject, new Error("missing authorization code")); + return; + } + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(LANDING); + settle(resolve, grant); + }); + onUrl?.(authUrl.toString()); + this.openBrowser(authUrl.toString()); + }); + + const exchange = await this.fetch(`https://${this.authDomain}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code_verifier: verifier, + code, + redirect_uri: redirectUri, + }), + }); + if (!exchange.ok) throw new Error(`token exchange failed (HTTP ${exchange.status})`); + const tokens = await exchange.json(); + + const userResponse = await this.fetch(`https://${this.authDomain}/userinfo`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + if (!userResponse.ok) throw new Error(`userinfo failed (HTTP ${userResponse.status})`); + const user = await userResponse.json(); + + let handle = null; + try { + const lookup = await this.fetch(`${this.site}/handle?for=${encodeURIComponent(user.sub)}`, { + headers: { "User-Agent": USER_AGENT, Accept: "application/json" }, + }); + const data = await lookup.json(); + if (data?.handle) handle = String(data.handle).replace(/^@/, ""); + } catch {} + + this.#write({ + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + id_token: tokens.id_token, + expires_at: this.now() + (tokens.expires_in || 3600) * 1000, + user: { email: user.email, name: user.name, sub: user.sub, picture: user.picture, handle }, + }); + this.emit("change", this.label()); + return handle; + } finally { + this.signingIn = false; + server.close(); + } + } + + logout({ browser = false } = {}) { + let removed = false; + try { + unlinkSync(this.file); + removed = true; + } catch {} + if (browser) { + const url = new URL(`https://${this.authDomain}/v2/logout`); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("returnTo", this.site); + this.openBrowser(url.toString()); + } + this.emit("change", this.label()); + return removed; + } + + #write(record) { + mkdirSync(dirname(this.file), { recursive: true }); + writeFileSync(this.file, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); + } +} diff --git a/src/app-server.mjs b/src/app-server.mjs index 465444417f..10a81e86e1 100644 --- a/src/app-server.mjs +++ b/src/app-server.mjs @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { createInterface } from "node:readline"; -const VERSION = "0.2.1"; +const VERSION = "0.3.0"; export class AppServer extends EventEmitter { constructor({ @@ -11,6 +11,7 @@ export class AppServer extends EventEmitter { command = "codex", args = ["app-server", "--stdio"], environment = {}, + developerInstructions = "", }) { super(); this.cwd = cwd; @@ -18,6 +19,7 @@ export class AppServer extends EventEmitter { this.args = args; this.environment = environment; this.resumeThreadId = resumeThreadId; + this.developerInstructions = developerInstructions; this.child = null; this.nextId = 1; this.pending = new Map(); @@ -78,6 +80,7 @@ export class AppServer extends EventEmitter { sandbox: "workspace-write", ephemeral: false, sessionStartSource: this.threadId ? "clear" : "startup", + ...(this.developerInstructions ? { developerInstructions: this.developerInstructions } : {}), }); this.threadId = result.thread.id; this.turnId = null; @@ -91,6 +94,7 @@ export class AppServer extends EventEmitter { approvalPolicy: "on-request", approvalsReviewer: "user", sandbox: "workspace-write", + ...(this.developerInstructions ? { developerInstructions: this.developerInstructions } : {}), }); this.threadId = result.thread.id; this.turnId = null; diff --git a/src/cli.mjs b/src/cli.mjs new file mode 100644 index 0000000000..100081c762 --- /dev/null +++ b/src/cli.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// cli.mjs — account and publish subcommands behind `aesthetic`. +import process from "node:process"; +import { ACSession } from "./ac-session.mjs"; +import { planPublish, publishPiece } from "./publish.mjs"; + +const [command = "", ...rest] = process.argv.slice(2); +const session = new ACSession(); +const out = (text) => process.stdout.write(`${text}\n`); +const note = (text) => process.stderr.write(`${text}\n`); +const fail = (message) => { + process.stderr.write(`aesthetic: ${message}\n`); + process.exit(1); +}; + +try { + if (command === "whoami") { + out(session.label()); + process.exit(session.signedIn ? 0 : 1); + } else if (command === "login") { + const handle = await session.login({ + forcePrompt: rest.includes("--fresh"), + onUrl: (url) => note(`Sign in at\n ${url}`), + }); + out(handle ? `Signed in as @${handle}` : "Signed in · no handle yet"); + } else if (command === "logout") { + out(session.logout({ browser: rest.includes("--browser") }) ? "Signed out" : "Already signed out"); + } else if (command === "publish") { + const [file, slug = ""] = rest.filter((argument) => !argument.startsWith("--")); + if (!file) fail("usage: aesthetic publish [slug]"); + if (process.env.AESTHETIC_CODE_DRY_RUN === "1") { + const plan = planPublish({ file, slug, handle: session.handle || "handle" }); + out(`would publish ${plan.path} as ${plan.route}`); + } else { + const result = await publishPiece({ file, slug, session, onStep: (step) => note(`${step}…`) }); + out(result.route); + if (!result.verified) note("published, but the live file did not read back yet"); + } + } else { + fail(`unknown command: ${command || "(none)"}`); + } +} catch (error) { + fail(error?.message || String(error)); +} diff --git a/src/publish.mjs b/src/publish.mjs new file mode 100644 index 0000000000..4fc64230ae --- /dev/null +++ b/src/publish.mjs @@ -0,0 +1,101 @@ +// publish.mjs — put a piece live under the signed-in user's @handle. +// +// This mirrors the web prompt's `publish` command exactly: ask the site for a +// presigned upload grant in the user bucket, PUT the source there, and the +// piece answers at https://aesthetic.computer/@handle/slug. It is not the +// anonymous /api/store-piece short-code path. +import { readFileSync } from "node:fs"; +import { basename, extname, resolve } from "node:path"; +import { SITE, USER_AGENT } from "./ac-session.mjs"; + +export const PUBLISHABLE = { + ".mjs": "application/javascript; charset=utf-8", + ".lisp": "text/x-lisp; charset=utf-8", +}; +export const MAX_SOURCE_LENGTH = 100_000; + +export function slugFor(file, explicit = "") { + const slug = String(explicit || basename(file, extname(file))).trim(); + if (!/^[a-zA-Z0-9_-]+$/.test(slug)) { + throw new Error(`slug "${slug}" may only use letters, digits, "-" and "_"`); + } + return slug; +} + +export function looksLikePiece(source, extension) { + if (extension === ".lisp") return source.trim().length > 0; + return /export\s+(?:async\s+)?(?:function\s+(?:boot|paint|sim|act|beat|leave|meta)\b|default\b|\{)/.test(source); +} + +export function planPublish({ file, slug: explicit = "", handle = "", cwd = process.cwd(), site = SITE }) { + const path = resolve(cwd, file); + const extension = extname(path).toLowerCase(); + const mime = PUBLISHABLE[extension]; + if (!mime) throw new Error(`only ${Object.keys(PUBLISHABLE).join(" and ")} pieces can be published`); + const slug = slugFor(path, explicit); + const name = `piece-${slug}${extension}`; + return { + path, + extension, + mime, + slug, + name, + grantUrl: `${site}/presigned-upload-url/${extension.slice(1)}/${name}/user`, + route: handle ? `${site}/@${handle}/${slug}` : "", + mediaUrl: handle ? `${site}/media/@${handle}/piece/${slug}${extension}` : "", + }; +} + +export async function publishPiece({ + file, + slug = "", + session, + fetch = globalThis.fetch, + cwd = process.cwd(), + site = SITE, + onStep = () => {}, +}) { + const handle = session.handle; + if (!handle) { + throw new Error( + session.signedIn + ? "this account has no @handle yet — claim one at aesthetic.computer/handle" + : "not signed in — run /login first", + ); + } + const plan = planPublish({ file, slug, handle, cwd, site }); + const source = readFileSync(plan.path, "utf8"); + if (source.length > MAX_SOURCE_LENGTH) { + throw new Error(`source is ${source.length} characters; the limit is ${MAX_SOURCE_LENGTH}`); + } + if (!looksLikePiece(source, plan.extension)) { + throw new Error("this file does not export a piece (boot, paint, sim, act, or default)"); + } + + const token = await session.token(); + onStep("requesting upload grant"); + const presign = await fetch(plan.grantUrl, { + headers: { Authorization: `Bearer ${token}`, "User-Agent": USER_AGENT, Accept: "application/json" }, + }); + const grant = await presign.json().catch(() => ({})); + if (!presign.ok || !grant.uploadURL) { + throw new Error(grant.error || grant.message || `upload grant failed (HTTP ${presign.status})`); + } + + onStep("uploading"); + const put = await fetch(grant.uploadURL, { + method: "PUT", + headers: { "Content-Type": plan.mime, "Content-Disposition": "inline", "x-amz-acl": "public-read" }, + body: source, + }); + if (!put.ok) throw new Error(`upload failed (HTTP ${put.status})`); + + onStep("verifying"); + let verified = false; + try { + const check = await fetch(plan.mediaUrl, { headers: { "User-Agent": USER_AGENT }, redirect: "follow" }); + verified = check.ok && (await check.text()).trim() === source.trim(); + } catch {} + + return { ...plan, handle, verified, bytes: Buffer.byteLength(source) }; +} diff --git a/src/render.mjs b/src/render.mjs index b42fdaf7a6..9e52595647 100644 --- a/src/render.mjs +++ b/src/render.mjs @@ -1,16 +1,62 @@ +// render.mjs — one frame of the Aesthetic Code interface. +// +// The palette is the Aesthetic Computer prompt's dark scheme (disks/prompt.mjs +// `scheme.dark`): purple ground, pink prompt block, orange highlight, magenta +// handle, light-purple secondary text. const ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; +export const palette = { + background: [70, 50, 100], + text: [255, 255, 255], + prompt: [200, 30, 100], + block: [200, 30, 100], + highlight: [255, 100, 0], + handle: [255, 100, 255], + soft: [220, 180, 255], + muted: [170, 150, 205], + status: [0, 255, 0], + error: [255, 90, 90], + you: [255, 90, 160], + run: [255, 160, 60], + edit: [130, 255, 130], +}; + +const truecolor = /truecolor|24bit/i.test(process.env.COLORTERM || ""); + +// xterm-256 approximation for terminals without 24-bit color (Terminal.app). +// The dark purple ground has no faithful cube entry, so it is pinned to a +// slate-purple index by hand instead of collapsing into gray. +const LEVELS = [0, 95, 135, 175, 215, 255]; +const PINNED = new Map([[palette.background.join(","), 60]]); +function cube(rgb) { + const pinned = PINNED.get(rgb.join(",")); + if (pinned !== undefined) return pinned; + const nearest = (value) => + LEVELS.reduce((best, level, index) => (Math.abs(level - value) < Math.abs(LEVELS[best] - value) ? index : best), 0); + const [r, g, b] = rgb.map(nearest); + return 16 + 36 * r + 6 * g + b; +} +const fg = (rgb) => (truecolor ? `\x1b[38;2;${rgb.join(";")}m` : `\x1b[38;5;${cube(rgb)}m`); +const bg = (rgb) => (truecolor ? `\x1b[48;2;${rgb.join(";")}m` : `\x1b[48;5;${cube(rgb)}m`); + export const color = { reset: "\x1b[0m", bold: "\x1b[1m", - dim: "\x1b[2m", - pink: "\x1b[38;5;205m", - cyan: "\x1b[38;5;80m", - green: "\x1b[38;5;84m", - yellow: "\x1b[38;5;221m", - red: "\x1b[38;5;203m", inverse: "\x1b[7m", + ground: bg(palette.background) + fg(palette.text), + text: fg(palette.text), + prompt: fg(palette.prompt), + highlight: fg(palette.highlight), + handle: fg(palette.handle), + soft: fg(palette.soft), + muted: fg(palette.muted), + status: fg(palette.status), + error: fg(palette.error), + you: fg(palette.you), + run: fg(palette.run), + edit: fg(palette.edit), + block: bg(palette.block) + fg(palette.text), }; export function cleanText(value) { @@ -56,40 +102,66 @@ export function wrapText(value, width) { return output; } +// Paint a span, then fall back to the purple ground so the row stays filled. function paint(enabled, tone, value) { - return enabled ? `${color[tone]}${value}${color.reset}` : value; + if (!enabled) return value; + const tones = tone.split(" ").map((name) => color[name] || "").join(""); + return `${tones}${value}${color.reset}${color.ground}`; } function pad(value, width) { return value + " ".repeat(Math.max(0, width - textWidth(value))); } +const STYLES = { + user: ["YOU", "you"], + assistant: ["AC", "soft"], + command: ["RUN", "run"], + change: ["EDIT", "edit"], + publish: ["PUB", "handle"], + notice: ["·", "muted"], + error: ["!", "error"], +}; + function entryLines(entry, width, useColor) { - const styles = { - user: ["YOU", "pink"], - assistant: ["AC", "cyan"], - command: ["RUN", "yellow"], - change: ["EDIT", "green"], - notice: ["·", "dim"], - error: ["!", "red"], - }; - const [label, tone] = styles[entry.kind] || styles.notice; + const [label, tone] = STYLES[entry.kind] || STYLES.notice; const prefix = `${label.padEnd(4)} `; const continuation = " ".repeat(5); - return wrapText(entry.text, Math.max(1, width - 5)).map((line, index) => - `${paint(useColor, tone, index === 0 ? prefix : continuation)}${line}`, + const bodyTone = entry.kind === "notice" ? "muted" : entry.kind === "error" ? "error" : "text"; + return wrapText(entry.text, Math.max(1, width - 5)).map( + (line, index) => `${paint(useColor, tone, index === 0 ? prefix : continuation)}${paint(useColor, bodyTone, line)}`, ); } +function statusTone(status) { + if (status === "ready") return "status"; + if (status === "working" || status === "interrupting" || status === "starting") return "prompt"; + if (status === "approval") return "highlight"; + if (status === "failed" || status === "offline") return "error"; + return "soft"; +} + export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const width = Math.max(32, columns); const height = Math.max(10, rows); - const workspace = clipText(state.workspace || "workspace", Math.max(8, width - 34)); + const ground = useColor ? color.ground : ""; + const reset = useColor ? color.reset : ""; + const mode = state.mode === "local" ? "LOCAL" : "REMOTE"; - const right = `${mode} · ${String(state.status || "ready").toUpperCase()}`; - const gap = " ".repeat(Math.max(1, width - 2 - textWidth("AESTHETIC CODE") - textWidth(right))); - const header = ` ${paint(useColor, "bold", "AESTHETIC CODE")}${gap}${paint(useColor, state.mode === "local" ? "green" : "yellow", right)} `; - const pathLine = paint(useColor, "dim", ` ${clipText(workspace, width - 2)}`); + const status = String(state.status || "ready").toUpperCase(); + const right = `${paint(useColor, state.mode === "local" ? "status" : "highlight", mode)} · ${paint(useColor, statusTone(state.status), status)}`; + const rightWidth = textWidth(`${mode} · ${status}`); + const account = state.account || "not signed in"; + const piece = state.piece ? clipText(state.piece, 24) : ""; + const leftPlain = `AESTHETIC CODE ${account}${piece ? ` ${piece}` : ""}`; + const left = + `${paint(useColor, "bold text", "AESTHETIC CODE")} ` + + `${paint(useColor, account.startsWith("@") ? "handle" : "muted", account)}` + + `${piece ? ` ${paint(useColor, "soft", piece)}` : ""}`; + const gap = " ".repeat(Math.max(1, width - 2 - textWidth(leftPlain) - rightWidth)); + const header = ` ${left}${gap}${right} `; + const workspace = clipText(state.workspace || "workspace", Math.max(8, width - 2)); + const pathLine = paint(useColor, "muted", ` ${workspace}`); const transcriptRows = height - 5; const transcript = state.entries.flatMap((entry) => entryLines(entry, width - 2, useColor)); @@ -99,7 +171,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { let prompt; if (state.approval) { const subject = clipText(state.approval.subject || "requested action", Math.max(4, width - 31)); - prompt = `${paint(useColor, "yellow", "ALLOW")} ${subject} ${paint(useColor, "bold", "y")} once ${paint(useColor, "bold", "a")} session ${paint(useColor, "bold", "n")} deny`; + prompt = `${paint(useColor, "highlight bold", "ALLOW")} ${subject} ${paint(useColor, "bold", "y")} once ${paint(useColor, "bold", "a")} session ${paint(useColor, "bold", "n")} deny`; } else { const input = Array.from(cleanText(state.input || "")); const cursor = Math.max(0, Math.min(state.cursor ?? input.length, input.length)); @@ -110,12 +182,19 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const before = visibleInput.slice(0, visibleCursor).join(""); const underCursor = visibleInput[visibleCursor] || " "; const after = visibleInput.slice(visibleCursor + 1).join(""); - const cursorCell = useColor ? paint(true, "inverse", underCursor) : underCursor; - prompt = `${paint(useColor, "pink", "›")} ${start > 0 ? "‹" : ""}${before}${cursorCell}${after}`; + const cursorCell = useColor ? paint(true, "block", underCursor) : underCursor; + prompt = `${paint(useColor, "prompt bold", "›")} ${start > 0 ? "‹" : ""}${before}${cursorCell}${after}`; } - const rule = paint(useColor, "dim", "─".repeat(width)); - const help = paint(useColor, "dim", state.busy ? " ctrl-c interrupt" : " /help · ctrl-c quit"); - const lines = [header, pathLine, ...visible.map((line) => ` ${line}`), rule, prompt, pad(help, width)]; - return lines.slice(0, height).map((line) => pad(line, width)).join("\n"); + const rule = paint(useColor, "muted", "─".repeat(width)); + const help = paint( + useColor, + "muted", + state.busy ? " ctrl-c interrupt" : " /help · /login · /publish · ctrl-c quit", + ); + const lines = [header, pathLine, ...visible.map((line) => ` ${line}`), rule, prompt, help]; + return lines + .slice(0, height) + .map((line) => `${ground}${pad(line, width)}${reset}`) + .join("\n"); } diff --git a/src/slab-session.mjs b/src/slab-session.mjs index 7ea66ffd82..770aa0f7fe 100644 --- a/src/slab-session.mjs +++ b/src/slab-session.mjs @@ -58,6 +58,7 @@ export class SlabSession { tty, agent_pid: pid, agent_type: "aesthetic-code", + handle: "", provider_agent_type: "codex", provider_session_id: "", updated: now(), @@ -83,6 +84,11 @@ export class SlabSession { this.#update({ provider_session_id: providerSessionId || "" }); } + // Which @handle this rock acts as (display only; never email or name). + identity(handle = "") { + this.#update({ handle: String(handle || "").replace(/^@/, "") }); + } + working(prompt = "") { this.#remove(this.awaiting); this.#touch(this.running); diff --git a/src/tui.mjs b/src/tui.mjs index 2030a336e7..3bcd862c69 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -2,7 +2,9 @@ import path from "node:path"; import process from "node:process"; +import { ACSession } from "./ac-session.mjs"; import { AppServer } from "./app-server.mjs"; +import { publishPiece } from "./publish.mjs"; import { cleanText, renderFrame } from "./render.mjs"; import { SlabSession } from "./slab-session.mjs"; @@ -14,6 +16,9 @@ const option = (name) => { const cwd = path.resolve(option("--cwd") || process.cwd()); const resumeThreadId = option("--resume"); const initialPrompt = option("--prompt"); +const PIECE_EXTENSIONS = new Set([".mjs", ".lisp"]); + +const session = new ACSession(); const state = { workspace: cwd, mode: "remote", @@ -24,6 +29,9 @@ const state = { history: [], historyIndex: 0, approval: null, + account: session.label(), + piece: "", + pieceFile: "", entries: [ { id: "privacy", @@ -33,11 +41,28 @@ const state = { ], }; +// The model must never mistake a file on disk for a published piece. +function developerInstructions() { + const account = session.handle + ? `The user is signed in to Aesthetic Computer as @${session.handle}.` + : "The user is not signed in to Aesthetic Computer; /login signs them in."; + return [ + "You are running inside Aesthetic Code, a terminal interface for Aesthetic Computer (AC) work.", + account, + "Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.", + "A piece is live only after the user runs the Aesthetic Code command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", + "When you finish a piece, end with the exact /publish command for the user to run. Never tell the user to visit a route that has not been published.", + "Dev servers: do not stop a dev server you were asked to start; say that it is still running.", + ].join("\n"); +} + const slabSession = new SlabSession({ cwd }); slabSession.start(); +slabSession.identity(session.handle); const engine = new AppServer({ cwd, resumeThreadId, + developerInstructions: developerInstructions(), environment: { SLAB_PROMPT_SESSION_ID: slabSession.sessionId, SLAB_TERMINAL_TTY: slabSession.tty, @@ -76,6 +101,7 @@ function redraw() { function finish(code = 0) { if (closing) return; closing = true; + session.unwatch(); slabSession.close(); engine.close(); process.stdin.setRawMode(false); @@ -88,12 +114,22 @@ function errorText(error) { return cleanText(error?.message || error || "unknown error"); } +// Track the piece under work from the files the agent touches. +function notePiece(file) { + if (!file) return; + const resolved = path.resolve(cwd, file); + if (!PIECE_EXTENSIONS.has(path.extname(resolved).toLowerCase())) return; + state.pieceFile = resolved; + state.piece = path.basename(resolved, path.extname(resolved)); +} + function itemSummary(item) { if (!item) return null; if (item.type === "commandExecution") return { kind: "command", text: item.command }; if (item.type === "fileChange") { - const files = (item.changes || []).map((change) => change.path).join(", "); - return { kind: "change", text: files || "workspace files" }; + const paths = (item.changes || []).map((change) => change.path).filter(Boolean); + for (const file of paths) notePiece(file); + return { kind: "change", text: paths.join(", ") || "workspace files" }; } if (item.type === "mcpToolCall") return { kind: "command", text: `${item.server} · ${item.tool}` }; if (item.type === "dynamicToolCall") return { kind: "command", text: item.tool }; @@ -112,6 +148,8 @@ function restoreThread(thread) { if (text) restored.push({ id: item.id, kind: "user", text: cleanText(text) }); } else if (item.type === "agentMessage" && item.text) { restored.push({ id: item.id, kind: "assistant", text: cleanText(item.text) }); + } else if (item.type === "fileChange") { + for (const change of item.changes || []) notePiece(change.path); } } } @@ -246,6 +284,77 @@ function answerApproval(character) { return true; } +// ── account + publish commands ────────────────────────────────────────── + +function refreshAccount(announce = false) { + const previous = state.account; + state.account = session.label(); + slabSession.identity(session.handle); + if (announce && previous !== state.account) { + addEntry("notice", session.signedIn ? `Signed in as ${state.account}` : "Signed out"); + } +} + +async function commandLogin() { + if (session.signingIn) { + addEntry("notice", "A sign-in is already waiting on the browser."); + return redraw(); + } + const id = addEntry("notice", "Opening the browser to sign in…"); + redraw(); + try { + const handle = await session.login({ + onUrl: (url) => { + updateEntry(id, "notice", `Sign in at ${url}`); + redraw(); + }, + }); + refreshAccount(); + updateEntry( + id, + "notice", + handle ? `Signed in as @${handle}` : "Signed in · claim a handle at aesthetic.computer/handle", + ); + } catch (error) { + updateEntry(id, "error", `Sign-in failed: ${errorText(error)}`); + } + redraw(); +} + +function commandLogout() { + const removed = session.logout(); + refreshAccount(); + addEntry("notice", removed ? "Signed out" : "Already signed out"); + redraw(); +} + +async function commandPublish(argumentText) { + const [file = state.pieceFile, slug = ""] = argumentText.split(/\s+/).filter(Boolean); + if (!file) { + addEntry("error", "Usage: /publish [slug] — no piece has been touched yet."); + return redraw(); + } + const id = addEntry("publish", `Publishing ${path.basename(file)}…`); + redraw(); + try { + const result = await publishPiece({ + file, + slug, + session, + cwd, + onStep: (step) => { + updateEntry(id, "publish", `Publishing ${path.basename(file)} · ${step}…`); + redraw(); + }, + }); + notePiece(result.path); + updateEntry(id, "publish", `${result.route}${result.verified ? "" : " · uploaded, not yet readable"}`); + } catch (error) { + updateEntry(id, "error", `Publish failed: ${errorText(error)}`); + } + redraw(); +} + async function submitInput() { const text = state.input.trim(); state.input = ""; @@ -254,22 +363,45 @@ async function submitInput() { if (!text) return redraw(); if (text.startsWith("/")) { - if (text === "/quit" || text === "/exit") return finish(); - if (text === "/clear") { + const [command, ...restWords] = text.split(/\s+/); + const rest = restWords.join(" "); + if (command === "/quit" || command === "/exit") return finish(); + if (command === "/clear") { state.entries = []; return redraw(); } - if (text === "/help") { - addEntry("notice", "/new · /clear · /quit ctrl-c interrupts a running turn"); + if (command === "/help") { + addEntry( + "notice", + "/login · /logout · /whoami · /publish [slug] · /piece [name] · /new · /clear · /quit ctrl-c interrupts a running turn", + ); + return redraw(); + } + if (command === "/login") return commandLogin(); + if (command === "/logout") return commandLogout(); + if (command === "/whoami") { + refreshAccount(); + addEntry("notice", state.account); + return redraw(); + } + if (command === "/publish") return commandPublish(rest); + if (command === "/piece") { + if (rest) notePiece(rest.endsWith(".mjs") || rest.endsWith(".lisp") ? rest : `${rest}.mjs`); + else { + state.piece = ""; + state.pieceFile = ""; + } + addEntry("notice", state.piece ? `Working on ${state.piece}` : "No current piece"); return redraw(); } - if (text === "/new") { + if (command === "/new") { if (state.busy) { addEntry("error", "Interrupt the current turn before starting a new thread."); } else { state.status = "starting"; redraw(); try { + engine.developerInstructions = developerInstructions(); await engine.newThread(); slabSession.connected(engine.threadId); state.entries = [{ kind: "notice", text: "New thread", id: `thread-${Date.now()}` }]; @@ -391,6 +523,12 @@ process.stdout.on("resize", redraw); process.on("SIGTERM", () => finish(143)); process.on("SIGHUP", () => finish(129)); +// A sign-in or sign-out anywhere in the AC suite shows up here live. +session.watch().on("change", () => { + refreshAccount(true); + redraw(); +}); + engine.on("notification", handleNotification); engine.on("request", handleRequest); engine.on("protocolError", (error) => { @@ -413,6 +551,9 @@ try { state.status = "ready"; if (resumeThreadId && !restoreThread(connection.thread)) addEntry("notice", "Resumed thread"); else if (connection?.model) addEntry("notice", `Ready · ${connection.model}`); + if (!session.signedIn) { + addEntry("notice", "Not signed in to Aesthetic Computer · /login to publish under your @handle"); + } redraw(); if (initialPrompt) { replaceInput(initialPrompt); diff --git a/test/ac-session.test.mjs b/test/ac-session.test.mjs new file mode 100644 index 0000000000..4b27724725 --- /dev/null +++ b/test/ac-session.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { ACSession, CLIENT_ID } from "../src/ac-session.mjs"; + +const jsonResponse = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +async function scratch(context) { + const root = await mkdtemp(join(tmpdir(), "aesthetic-code-session-")); + context.after(() => rm(root, { recursive: true, force: true })); + return join(root, ".ac-token"); +} + +test("labels the three sign-in states without exposing email", async (context) => { + const file = await scratch(context); + const session = new ACSession({ file }); + assert.equal(session.state, "signed-out"); + assert.equal(session.label(), "not signed in"); + + await writeFile(file, JSON.stringify({ access_token: "t", user: { email: "x@y.z", handle: null } })); + assert.equal(session.state, "no-handle"); + assert.equal(session.label(), "signed in · no handle"); + + await writeFile(file, JSON.stringify({ access_token: "t", user: { email: "x@y.z", handle: "tester" } })); + assert.equal(session.state, "signed-in"); + assert.equal(session.handle, "tester"); + assert.equal(session.label(), "@tester"); + assert.doesNotMatch(session.label(), /x@y\.z/); +}); + +test("refreshes a stale access token in place", async (context) => { + const file = await scratch(context); + await writeFile( + file, + JSON.stringify({ access_token: "old", refresh_token: "r1", expires_at: 1_000, user: { handle: "tester" } }), + ); + const calls = []; + const session = new ACSession({ + file, + now: () => 10_000_000, + fetch: async (url, options) => { + calls.push({ url, body: JSON.parse(options.body) }); + return jsonResponse({ access_token: "new", refresh_token: "r2", expires_in: 3600 }); + }, + }); + assert.equal(await session.token(), "new"); + assert.equal(calls.length, 1); + assert.match(calls[0].url, /hi\.aesthetic\.computer\/oauth\/token$/); + assert.equal(calls[0].body.grant_type, "refresh_token"); + assert.equal(calls[0].body.client_id, CLIENT_ID); + const written = JSON.parse(await readFile(file, "utf8")); + assert.equal(written.access_token, "new"); + assert.equal(written.refresh_token, "r2"); + assert.equal(written.expires_at, 10_000_000 + 3_600_000); + assert.equal(written.user.handle, "tester"); +}); + +test("runs the PKCE sign-in against a loopback callback", async (context) => { + const file = await scratch(context); + const seen = {}; + const session = new ACSession({ + file, + callbackPort: 0, + site: "https://example.test", + fetch: async (url, options = {}) => { + if (url.endsWith("/oauth/token")) { + seen.exchange = JSON.parse(options.body); + return jsonResponse({ access_token: "acc", refresh_token: "ref", id_token: "id", expires_in: 86_400 }); + } + if (url.endsWith("/userinfo")) { + seen.auth = options.headers.Authorization; + return jsonResponse({ sub: "auth0|123", email: "x@y.z", name: "x" }); + } + if (url.startsWith("https://example.test/handle?for=")) { + seen.lookup = url; + return jsonResponse({ handle: "tester" }); + } + throw new Error(`unexpected fetch ${url}`); + }, + openBrowser: (authUrl) => { + const url = new URL(authUrl); + seen.challenge = url.searchParams.get("code_challenge"); + assert.equal(url.searchParams.get("client_id"), CLIENT_ID); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + const callback = new URL(url.searchParams.get("redirect_uri")); + callback.searchParams.set("code", "the-code"); + callback.searchParams.set("state", url.searchParams.get("state")); + // Play the browser: land on the loopback callback. + globalThis.fetch(callback).catch(() => {}); + }, + }); + const handle = await session.login({ timeoutMs: 5_000 }); + assert.equal(handle, "tester"); + assert.equal(seen.exchange.grant_type, "authorization_code"); + assert.equal(seen.exchange.code, "the-code"); + assert.ok(seen.exchange.code_verifier.length > 40); + assert.equal(seen.auth, "Bearer acc"); + assert.equal(seen.lookup, "https://example.test/handle?for=auth0%7C123"); + const written = JSON.parse(await readFile(file, "utf8")); + assert.equal(written.user.handle, "tester"); + assert.equal(written.user.sub, "auth0|123"); + assert.equal(session.label(), "@tester"); + assert.equal(session.logout(), true); + assert.equal(session.state, "signed-out"); +}); + +test("emits change when the shared token file is rewritten", async (context) => { + const file = await scratch(context); + const session = new ACSession({ file }); + context.after(() => session.unwatch()); + const changed = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no change event")), 4_000); + session.once("change", (label) => { + clearTimeout(timer); + resolve(label); + }); + }); + session.watch(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await writeFile(file, JSON.stringify({ access_token: "t", user: { handle: "tester" } })); + assert.equal(await changed, "@tester"); +}); diff --git a/test/cli.sh b/test/cli.sh index 5cd6eb17af..3fedea51d8 100755 --- a/test/cli.sh +++ b/test/cli.sh @@ -31,7 +31,7 @@ assert_contains() { } output="$($CLI --version)" -assert_contains "$output" 'Aesthetic Code 0.2.1' +assert_contains "$output" 'Aesthetic Code 0.3.0' output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" assert_contains "$output" 'interface=aesthetic-code' @@ -48,6 +48,29 @@ assert_contains "$output" 'initial_prompt=yes' output="$($CLI doctor)" assert_contains "$output" 'control plane: local' assert_contains "$output" 'telemetry: off' +assert_contains "$output" 'account: ' + +# Account commands read the shared ~/.ac-token under $HOME. +FAKE_HOME="$TEST_ROOT/home" +mkdir -p "$FAKE_HOME" +if output="$(HOME="$FAKE_HOME" "$CLI" whoami 2>&1)"; then + printf 'Expected whoami to exit non-zero when signed out.\n' >&2 + exit 1 +fi +assert_contains "$output" 'not signed in' + +printf '{"access_token":"t","user":{"handle":"tester"}}' > "$FAKE_HOME/.ac-token" +output="$(HOME="$FAKE_HOME" "$CLI" whoami)" +assert_contains "$output" '@tester' + +printf 'export function paint() {}\n' > "$WORK_DIR/smiley.mjs" +output="$(HOME="$FAKE_HOME" AESTHETIC_CODE_DRY_RUN=1 "$CLI" publish "$WORK_DIR/smiley.mjs")" +assert_contains "$output" 'https://aesthetic.computer/@tester/smiley' + +if HOME="$FAKE_HOME" AESTHETIC_CODE_DRY_RUN=1 "$CLI" publish >/dev/null 2>&1; then + printf 'Expected publish without a file to fail.\n' >&2 + exit 1 +fi if AESTHETIC_CODE_DRY_RUN=1 "$CLI" claude >/dev/null 2>&1; then printf 'Expected the removed provider shortcut to fail.\n' >&2 diff --git a/test/publish.test.mjs b/test/publish.test.mjs new file mode 100644 index 0000000000..dfa630a0ab --- /dev/null +++ b/test/publish.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { looksLikePiece, planPublish, publishPiece, slugFor } from "../src/publish.mjs"; + +const SOURCE = "export function paint({ wipe }) { wipe(70, 50, 100); }\n"; + +async function piece(context, name = "smiley.mjs", source = SOURCE) { + const root = await mkdtemp(join(tmpdir(), "aesthetic-code-publish-")); + context.after(() => rm(root, { recursive: true, force: true })); + const file = join(root, name); + await writeFile(file, source); + return { root, file }; +} + +test("plans the handle route from the file name", () => { + const plan = planPublish({ file: "/work/smiley.mjs", handle: "tester" }); + assert.equal(plan.slug, "smiley"); + assert.equal(plan.name, "piece-smiley.mjs"); + assert.equal(plan.grantUrl, "https://aesthetic.computer/presigned-upload-url/mjs/piece-smiley.mjs/user"); + assert.equal(plan.route, "https://aesthetic.computer/@tester/smiley"); + assert.equal(plan.mediaUrl, "https://aesthetic.computer/media/@tester/piece/smiley.mjs"); + assert.equal(planPublish({ file: "/work/x.lisp", slug: "face", handle: "t" }).route, "https://aesthetic.computer/@t/face"); + assert.throws(() => slugFor("/work/bad name.mjs"), /letters, digits/); + assert.throws(() => planPublish({ file: "/work/notes.txt", handle: "t" }), /\.mjs and \.lisp/); + assert.equal(looksLikePiece(SOURCE, ".mjs"), true); + assert.equal(looksLikePiece("const x = 1;", ".mjs"), false); +}); + +test("publishes through the presigned user-bucket flow and verifies", async (context) => { + const { file } = await piece(context); + const calls = []; + const session = { handle: "tester", signedIn: true, token: async () => "tok" }; + const result = await publishPiece({ + file, + session, + fetch: async (url, options = {}) => { + calls.push({ url, options }); + if (url.includes("/presigned-upload-url/")) { + return new Response(JSON.stringify({ uploadURL: "https://bucket.test/sub/piece/smiley.mjs?sig=1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (options.method === "PUT") return new Response("", { status: 200 }); + return new Response(SOURCE, { status: 200 }); + }, + }); + assert.equal(result.route, "https://aesthetic.computer/@tester/smiley"); + assert.equal(result.verified, true); + assert.equal(calls.length, 3); + assert.equal(calls[0].url, "https://aesthetic.computer/presigned-upload-url/mjs/piece-smiley.mjs/user"); + assert.equal(calls[0].options.headers.Authorization, "Bearer tok"); + assert.match(calls[0].options.headers["User-Agent"], /Mozilla/); + assert.equal(calls[1].url, "https://bucket.test/sub/piece/smiley.mjs?sig=1"); + assert.equal(calls[1].options.method, "PUT"); + assert.equal(calls[1].options.headers["Content-Type"], "application/javascript; charset=utf-8"); + assert.equal(calls[1].options.headers["x-amz-acl"], "public-read"); + assert.equal(calls[1].options.body, SOURCE); + assert.equal(calls[2].url, "https://aesthetic.computer/media/@tester/piece/smiley.mjs"); +}); + +test("refuses to publish without a signed-in handle or a real piece", async (context) => { + const { file } = await piece(context); + await assert.rejects( + publishPiece({ file, session: { handle: "", signedIn: false }, fetch: async () => assert.fail("no network") }), + /not signed in/, + ); + await assert.rejects( + publishPiece({ file, session: { handle: "", signedIn: true }, fetch: async () => assert.fail("no network") }), + /no @handle/, + ); + const { file: plain } = await piece(context, "plain.mjs", "const x = 1;\n"); + await assert.rejects( + publishPiece({ file: plain, session: { handle: "t", signedIn: true, token: async () => "tok" } }), + /does not export a piece/, + ); +}); diff --git a/test/render.test.mjs b/test/render.test.mjs index eab4a2db87..48a2c9bbb4 100644 --- a/test/render.test.mjs +++ b/test/render.test.mjs @@ -38,6 +38,28 @@ test("renders one branded interface with privacy state and prompt", () => { assert.equal(frame.split("\n").length, 20); }); +test("shows the signed-in handle and the current piece in the header", () => { + const frame = renderFrame( + { + workspace: "/project", + mode: "remote", + status: "ready", + busy: false, + input: "", + entries: [{ id: "p", kind: "publish", text: "https://aesthetic.computer/@tester/smiley" }], + account: "@tester", + piece: "smiley", + }, + 70, + 12, + false, + ); + assert.match(frame, /AESTHETIC CODE @tester smiley/); + assert.match(frame, /REMOTE · READY/); + assert.match(frame, /PUB https:\/\/aesthetic\.computer\/@tester\/smiley/); + assert.match(renderFrame({ workspace: "/p", mode: "remote", status: "ready", entries: [], input: "" }, 60, 12, false), /not signed in/); +}); + test("renders approvals inside the interface", () => { const frame = renderFrame( { -- 2.51.2 From 15d0a15ace4481ed0f0ffe2caef5a882c63a42bb Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 12:37:58 -0400 Subject: [PATCH 05/18] Open every session on a blank piece with a QR code that runs it live Aesthetic Code now mints a new piece the moment it opens: a random pronounceable name, a real file in the workspace, and a QR code in the bottom right that runs it on a phone. - src/live.mjs pushes every save to /run on a private code channel; the QR opens prompt~channel~~!autorun, so a phone joins the same channel the VS Code extension has always used - src/qr.mjs draws a scannable code from AC's own vendored encoder, two modules per character cell, black on white with a quiet zone - src/runtimes.mjs carries JavaScript, KidLisp and Lua blanks; /runtime and --runtime switch between them - an untouched blank is removed on exit, so opening and closing the interface leaves no litter - the header clips instead of overflowing in a narrow window --- README.md | 32 +- bin/aesthetic | 18 +- docs/local-contract.md | 11 + package.json | 2 +- src/app-server.mjs | 2 +- src/live.mjs | 205 +++++++++ src/names.mjs | 34 ++ src/qr.mjs | 39 ++ src/render.mjs | 46 +- src/runtimes.mjs | 85 ++++ src/tui.mjs | 95 +++- src/vendor/qr.mjs | 985 +++++++++++++++++++++++++++++++++++++++++ test/cli.sh | 13 +- test/live.test.mjs | 191 ++++++++ test/render.test.mjs | 36 +- 15 files changed, 1762 insertions(+), 32 deletions(-) create mode 100644 src/live.mjs create mode 100644 src/names.mjs create mode 100644 src/qr.mjs create mode 100644 src/runtimes.mjs create mode 100644 src/vendor/qr.mjs create mode 100644 test/live.test.mjs diff --git a/README.md b/README.md index bb4ad86421..3dfeb01e4e 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,34 @@ The interface uses the Aesthetic Computer prompt's palette (purple ground, pink 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 [slug]`, -`/piece [name]`, `/new`, `/clear`, `/help`, `/quit`. Press `ctrl-c` to interrupt -a running turn or exit while idle. +Inside the TUI: `/login`, `/logout`, `/whoami`, `/publish [file] [slug]`, +`/piece [name]`, `/runtime [mjs|lisp|lua]`, `/qr`, `/live`, `/new`, `/clear`, +`/help`, `/quit`. Press `ctrl-c` to interrupt a running turn or exit while idle. + +## The session's piece, live on a phone + +Opening Aesthetic Code opens a new blank piece. It gets a random pronounceable +name, it is a real file in the workspace, and a QR code for it sits in the +bottom right of the interface. Scan the code and the piece runs on your phone; +every edit the agent makes reaches it a moment later. + +The link works through the code channel Aesthetic Computer already uses for +live editing. The QR opens `prompt~channel~~!autorun`, which runs the +prompt's own `channel` command, and the interface POSTs each save to `/run`, +which relays it over Redis and the session server to everything watching that +channel. It is the same path the VS Code extension has always used, so nothing +new runs on the server. + +A blank that is never edited is deleted when the session ends, so opening and +closing the interface leaves the workspace exactly as it was. In the Aesthetic +Computer repository the piece is written to `system/public/aesthetic.computer/ +disks/`; anywhere else it lands in the workspace root. + +`/runtime` switches the language of the piece — `mjs` (JavaScript), `lisp` +(KidLisp), or `lua` — and rewrites the blank. `--runtime` picks it at launch. +Live pushes work for all three; `aesthetic.computer/@handle/` resolves +`.mjs` and `.lisp` today, so a Lua piece runs live but has no published route +yet. ## Account and publishing @@ -50,6 +75,7 @@ Writing a file under `disks/` does not publish it, and the engine is told so. aesthetic login aesthetic whoami aesthetic publish system/public/aesthetic.computer/disks/smiley.mjs +ac --runtime lisp ``` ```sh diff --git a/bin/aesthetic b/bin/aesthetic index e21c60c786..498fe7cb3b 100755 --- a/bin/aesthetic +++ b/bin/aesthetic @@ -2,7 +2,7 @@ set -euo pipefail -VERSION="0.3.0" +VERSION="0.4.0" script_path="${BASH_SOURCE[0]}" while [[ -L "$script_path" ]]; do script_directory="$(cd "$(dirname "$script_path")" && pwd -P)" @@ -17,7 +17,7 @@ PROJECT_DIR="$(cd "$(dirname "$script_path")/.." && pwd -P)" usage() { cat <<'EOF' -Usage: ac [directory] +Usage: ac [directory] [--runtime mjs|lisp|lua] aesthetic [directory] aesthetic doctor aesthetic login | logout | whoami @@ -26,6 +26,9 @@ Usage: ac [directory] Open the Aesthetic Code terminal interface in a workspace, or manage the shared Aesthetic Computer sign-in (~/.ac-token) and publish a piece under your @handle at https://aesthetic.computer/@handle/slug. + +Each session opens on a new blank piece and shows a QR code that runs it +live on a phone. --runtime picks the language of that piece. EOF } @@ -84,6 +87,7 @@ esac target_directory="" resume_thread="" initial_prompt="" +runtime="" while [[ $# -gt 0 ]]; do case "$1" in --resume) @@ -98,6 +102,14 @@ while [[ $# -gt 0 ]]; do initial_prompt="$2" shift 2 ;; + --runtime) + [[ $# -ge 2 ]] || fail "--runtime requires a language" + case "$2" in + mjs|lisp|lua) runtime="$2" ;; + *) fail "unknown runtime: $2 (mjs, lisp, lua)" ;; + esac + shift 2 + ;; --) shift [[ $# -le 1 ]] || fail "expected one workspace directory" @@ -127,6 +139,7 @@ if [[ "${AESTHETIC_CODE_DRY_RUN:-0}" == "1" ]]; then printf 'directory=%s\n' "$target_directory" printf 'resume=%s\n' "$([[ -n "$resume_thread" ]] && printf yes || printf no)" printf 'initial_prompt=%s\n' "$([[ -n "$initial_prompt" ]] && printf yes || printf no)" + printf 'runtime=%s\n' "${runtime:-mjs}" exit 0 fi @@ -137,4 +150,5 @@ export AESTHETIC_CODE_VERSION="$VERSION" arguments=("--cwd" "$target_directory") if [[ -n "$resume_thread" ]]; then arguments+=("--resume" "$resume_thread"); fi if [[ -n "$initial_prompt" ]]; then arguments+=("--prompt" "$initial_prompt"); fi +if [[ -n "$runtime" ]]; then arguments+=("--runtime" "$runtime"); fi exec node "$PROJECT_DIR/src/tui.mjs" "${arguments[@]}" diff --git a/docs/local-contract.md b/docs/local-contract.md index 8434c2c6ec..2823b5baa5 100644 --- a/docs/local-contract.md +++ b/docs/local-contract.md @@ -21,6 +21,17 @@ 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. +## Live piece boundary + +The session's piece is pushed to `aesthetic.computer/run` on a private code +channel every time its file changes, so that a phone that scanned the QR code +can run 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. + +Pushing is the interface's own action, on a file the user can see, and stops +when the session ends. + ## Inference boundary The terminal interface is always Aesthetic Code. Engines are internal bridges, diff --git a/package.json b/package.json index 44d22922da..24274b0ffe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aesthetic-code", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/src/app-server.mjs b/src/app-server.mjs index 10a81e86e1..822423d0e4 100644 --- a/src/app-server.mjs +++ b/src/app-server.mjs @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { createInterface } from "node:readline"; -const VERSION = "0.3.0"; +const VERSION = "0.4.0"; export class AppServer extends EventEmitter { constructor({ diff --git a/src/live.mjs b/src/live.mjs new file mode 100644 index 0000000000..945675386a --- /dev/null +++ b/src/live.mjs @@ -0,0 +1,205 @@ +// live.mjs — the session's piece, and the channel that carries it to a phone. +// +// Every Aesthetic Code session opens on a new blank piece with a random name. +// The piece is a real file in the workspace, so the agent edits it like any +// other file, and every save is pushed to Aesthetic Computer's `/run` endpoint +// on a private code channel. Anything watching that channel — a phone that +// scanned the QR code — hot-reloads the new source immediately. +// +// This is the path the VS Code extension has always used: POST /run publishes +// { piece, source, codeChannel } to Redis, the session server relays it to the +// channel's subscribers, and the client swaps the running piece. A viewer joins +// the channel by opening `prompt~channel~~!autorun`, which runs the +// prompt's own `channel` command on arrival. +import { EventEmitter } from "node:events"; +import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; +import { basename, dirname, extname, join, resolve } from "node:path"; +import { USER_AGENT } from "./ac-session.mjs"; +import { randomChannel, randomSlug } from "./names.mjs"; +import { DEFAULT_RUNTIME, runtimeFor, runtimeForExtension } from "./runtimes.mjs"; + +export const SITE = "https://aesthetic.computer"; +// Scannable codes get smaller the shorter the text, and every phone camera +// adds the scheme back, so the QR carries a bare host. +export const SCAN_HOST = "aesthetic.computer"; +const DISKS = join("system", "public", "aesthetic.computer", "disks"); + +// Pieces belong in the disks folder when the workspace is the Aesthetic +// Computer repository; anywhere else, the workspace root is the right home. +export function pieceDirectory(cwd) { + const disks = join(cwd, DISKS); + return existsSync(disks) ? disks : cwd; +} + +export class LivePiece extends EventEmitter { + constructor({ + cwd = process.cwd(), + slug = randomSlug(), + runtime = DEFAULT_RUNTIME, + channel = randomChannel(), + directory = pieceDirectory(cwd), + fetch = globalThis.fetch, + site = SITE, + scanHost = SCAN_HOST, + } = {}) { + super(); + this.cwd = cwd; + this.slug = slug; + this.runtime = runtimeFor(runtime); + this.channel = channel; + this.directory = directory; + this.fetch = fetch; + this.site = site; + this.scanHost = scanHost; + // The exact blank written for the current file. A file still matching it + // has never been touched, which is how an abandoned session knows what it + // may delete. Deriving this from the content rather than tracking a flag + // keeps the directory watcher — which fires on our own writes — out of it. + this.blank = ""; + this.watcher = null; + this.debounce = null; + this.pushes = 0; + } + + get file() { + return join(this.directory, `${this.slug}${this.runtime.extension}`); + } + + // The URL a phone scans: it joins the code channel and then sits waiting for + // source, which arrives as soon as anything is pushed. + get scanUrl() { + return `${this.scanHost}/prompt~channel~${this.channel}~!autorun`; + } + + // Where the piece answers once it has been published under a handle. + publishedUrl(handle) { + return handle ? `${this.site}/@${handle}/${this.slug}` : ""; + } + + // Write the blank piece if nothing is there yet. A blank that is never + // edited is removed again on exit, so launching the interface and closing it + // leaves no litter in the workspace. + create() { + mkdirSync(this.directory, { recursive: true }); + if (existsSync(this.file)) { + this.blank = ""; + return this.file; + } + this.blank = this.runtime.blank(this.slug); + writeFileSync(this.file, this.blank); + return this.file; + } + + // True while the piece is still exactly the blank this session wrote. + get pristine() { + if (!this.blank) return false; + try { + return readFileSync(this.file, "utf8") === this.blank; + } catch { + return false; + } + } + + // Drop the blank at `path` if nothing ever edited it. + #discard(path, blank) { + if (!blank) return false; + try { + if (readFileSync(path, "utf8") !== blank) return false; + rmSync(path, { force: true }); + return true; + } catch { + return false; + } + } + + source() { + try { + return readFileSync(this.file, "utf8"); + } catch { + return ""; + } + } + + // Point at a different piece — the agent wrote or edited something else, or + // the user renamed the session's piece. + retarget(file) { + const path = resolve(this.cwd, file); + const runtime = runtimeForExtension(extname(path)); + if (!runtime) return false; + const slug = basename(path, runtime.extension); + if (path === this.file) return false; + const previous = this.file; + const previousBlank = this.blank; + this.directory = dirname(path); + this.slug = slug; + this.runtime = runtime; + this.blank = ""; + this.#discard(previous, previousBlank); // The blank was never used. + this.emit("retarget", this.slug); + return true; + } + + // Rename the session's piece, carrying the blank file along with it. + rename(name, runtimeId = this.runtime.id) { + const slug = String(name || "").trim(); + if (!/^[a-zA-Z0-9_-]+$/.test(slug)) { + throw new Error(`piece names may only use letters, digits, "-" and "_"`); + } + const previous = this.file; + const previousBlank = this.blank; + this.slug = slug; + this.runtime = runtimeFor(runtimeId); + this.blank = ""; + this.create(); + if (previous !== this.file) this.#discard(previous, previousBlank); + return this.file; + } + + // Push the current source onto the code channel. + async push() { + const source = this.source(); + if (!source.trim()) return false; + const response = await this.fetch(`${this.site}/run`, { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT }, + body: JSON.stringify({ piece: this.slug, source, codeChannel: this.channel }), + }); + if (!response.ok) throw new Error(`live push failed (HTTP ${response.status})`); + this.pushes += 1; + this.emit("push", this.pushes); + return true; + } + + // Watch the whole directory rather than the file: editors and patch tools + // replace files instead of writing through them, which leaves a file watch + // pointed at a discarded inode. + watch(onError = () => {}) { + this.unwatch(); + try { + this.watcher = watch(this.directory, (_event, name) => { + if (name && name !== basename(this.file)) return; + clearTimeout(this.debounce); + this.debounce = setTimeout(() => { + this.push().catch(onError); + }, 250); + this.debounce.unref?.(); + }); + this.watcher.unref?.(); + } catch {} + return this; + } + + unwatch() { + clearTimeout(this.debounce); + try { + this.watcher?.close(); + } catch {} + this.watcher = null; + } + + // Remove an untouched blank so an abandoned session leaves nothing behind. + cleanup() { + this.unwatch(); + return this.#discard(this.file, this.blank); + } +} diff --git a/src/names.mjs b/src/names.mjs new file mode 100644 index 0000000000..12e2161634 --- /dev/null +++ b/src/names.mjs @@ -0,0 +1,34 @@ +// names.mjs — pronounceable random piece names. +// +// A session's blank piece needs a name before anyone has decided what the +// piece is. These are the same shape as Slab's prompt-rock pet names: +// alternating consonant and vowel syllables, easy to say out loud and short +// enough to keep the QR code small. +import { randomBytes } from "node:crypto"; + +const CONSONANTS = "bdfgklmnprstvz"; +const VOWELS = "aeiou"; + +function pick(alphabet, byte) { + return alphabet[byte % alphabet.length]; +} + +// Two or three syllables: movika, tesu, blorenda-free and always typeable. +export function randomSlug({ syllables = 0, bytes = randomBytes(8) } = {}) { + const count = syllables || 2 + (bytes[0] % 2); + let name = ""; + for (let index = 0; index < count; index += 1) { + name += pick(CONSONANTS, bytes[(index * 2 + 1) % bytes.length]); + name += pick(VOWELS, bytes[(index * 2 + 2) % bytes.length]); + } + return name; +} + +// The code channel is a shared secret: anyone who knows it can push source to +// whatever is watching. It is never shown as a name, so make it unguessable. +// Eight base64url characters is 48 bits of entropy, and keeps the scan URL +// under the 53 bytes that fit a version-3 QR code — the largest code that +// still fits a terminal corner. +export function randomChannel(bytes = randomBytes(8)) { + return Buffer.from(bytes).toString("base64url").slice(0, 8); +} diff --git a/src/qr.mjs b/src/qr.mjs new file mode 100644 index 0000000000..0052c433a0 --- /dev/null +++ b/src/qr.mjs @@ -0,0 +1,39 @@ +// qr.mjs — a scannable QR code in a terminal. +// +// Two QR modules share one character cell: the upper half block takes the +// foreground colour for the top module and the background colour for the +// bottom one, which keeps the modules square. Codes are always drawn as black +// on white with a quiet zone, regardless of the terminal's own theme, because +// a camera needs the contrast and the margin to find the code at all. +import { ErrorCorrectLevel, qrcode } from "./vendor/qr.mjs"; + +const UPPER = "\u2580"; // Upper half block. +const BLACK = 0; +const WHITE = 15; +const QUIET = 2; // Modules of margin on every side. + +const cell = (top, bottom) => + `\x1b[38;5;${top ? BLACK : WHITE}m\x1b[48;5;${bottom ? BLACK : WHITE}m${UPPER}`; + +// Render `text` as lines of ANSI. Each line already resets at its end, so the +// block can be pasted into any frame without leaking colour. +export function qrBlock(text, { quiet = QUIET } = {}) { + const modules = qrcode(String(text), { errorCorrectLevel: ErrorCorrectLevel.L }).modules; + const span = modules.length + quiet * 2; + const dark = (x, y) => { + const column = x - quiet; + const row = y - quiet; + if (column < 0 || row < 0 || column >= modules.length || row >= modules.length) return false; + return Boolean(modules[row][column]); + }; + + const lines = []; + for (let y = 0; y < span; y += 2) { + let line = ""; + for (let x = 0; x < span; x += 1) { + line += cell(dark(x, y), y + 1 < span ? dark(x, y + 1) : false); + } + lines.push(`${line}\x1b[0m`); + } + return { lines, width: span, height: lines.length, text: String(text) }; +} diff --git a/src/render.mjs b/src/render.mjs index 9e52595647..0eee4158b7 100644 --- a/src/render.mjs +++ b/src/render.mjs @@ -151,23 +151,51 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const status = String(state.status || "ready").toUpperCase(); const right = `${paint(useColor, state.mode === "local" ? "status" : "highlight", mode)} · ${paint(useColor, statusTone(state.status), status)}`; const rightWidth = textWidth(`${mode} · ${status}`); - const account = state.account || "not signed in"; - const piece = state.piece ? clipText(state.piece, 24) : ""; - const leftPlain = `AESTHETIC CODE ${account}${piece ? ` ${piece}` : ""}`; + + // A narrow window drops the piece, then the account, rather than pushing the + // status off the end of the row. + const room = Math.max(0, width - 3 - rightWidth); + const title = "AESTHETIC CODE"; + let account = state.account || "not signed in"; + let piece = state.piece ? clipText(state.piece, 24) : ""; + if (textWidth(`${title} ${account} ${piece}`) > room) piece = ""; + if (textWidth(`${title} ${account}`) > room) account = ""; + const leftPlain = clipText( + `${title}${account ? ` ${account}` : ""}${piece ? ` ${piece}` : ""}`, + room, + ); const left = - `${paint(useColor, "bold text", "AESTHETIC CODE")} ` + - `${paint(useColor, account.startsWith("@") ? "handle" : "muted", account)}` + - `${piece ? ` ${paint(useColor, "soft", piece)}` : ""}`; + leftPlain === title || !account + ? paint(useColor, "bold text", leftPlain) + : `${paint(useColor, "bold text", title)} ` + + `${paint(useColor, account.startsWith("@") ? "handle" : "muted", account)}` + + `${piece ? ` ${paint(useColor, "soft", piece)}` : ""}`; const gap = " ".repeat(Math.max(1, width - 2 - textWidth(leftPlain) - rightWidth)); const header = ` ${left}${gap}${right} `; const workspace = clipText(state.workspace || "workspace", Math.max(8, width - 2)); const pathLine = paint(useColor, "muted", ` ${workspace}`); const transcriptRows = height - 5; - const transcript = state.entries.flatMap((entry) => entryLines(entry, width - 2, useColor)); + // The QR code keeps its own column on the right, so the transcript is + // narrowed rather than overdrawn. A code is an image, not text: it needs its + // own black on white to be scannable, so a window with colour switched off or + // too little room shows the scan URL instead and drops the code. + const qr = + useColor && state.qr && width >= state.qr.width + 24 && transcriptRows >= state.qr.height + 2 + ? state.qr + : null; + const contentWidth = qr ? width - qr.width - 2 : width - 2; + const transcript = state.entries.flatMap((entry) => entryLines(entry, contentWidth, useColor)); const visible = transcript.slice(Math.max(0, transcript.length - transcriptRows)); while (visible.length < transcriptRows) visible.unshift(""); + const body = visible.map((line, index) => { + const row = ` ${pad(line, contentWidth)}`; + if (!qr) return row; + const band = index - (transcriptRows - qr.height); + return band >= 0 ? `${row} ${qr.lines[band]}` : row; + }); + let prompt; if (state.approval) { const subject = clipText(state.approval.subject || "requested action", Math.max(4, width - 31)); @@ -190,9 +218,9 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const help = paint( useColor, "muted", - state.busy ? " ctrl-c interrupt" : " /help · /login · /publish · ctrl-c quit", + clipText(state.busy ? " ctrl-c interrupt" : " /help · /login · /publish · /qr · ctrl-c quit", width), ); - const lines = [header, pathLine, ...visible.map((line) => ` ${line}`), rule, prompt, help]; + const lines = [header, pathLine, ...body, rule, prompt, help]; return lines .slice(0, height) .map((line) => `${ground}${pad(line, width)}${reset}`) diff --git a/src/runtimes.mjs b/src/runtimes.mjs new file mode 100644 index 0000000000..7be30023d3 --- /dev/null +++ b/src/runtimes.mjs @@ -0,0 +1,85 @@ +// runtimes.mjs — the piece languages Aesthetic Computer can run. +// +// A piece is JavaScript, KidLisp, or Lua, chosen by file extension. The blank +// templates are the starting point for a session's piece: each one paints +// something on its own so a phone pointed at the QR code has an image +// immediately, before the agent has written anything. + +export const RUNTIMES = { + mjs: { + id: "mjs", + label: "javascript", + extension: ".mjs", + mime: "application/javascript; charset=utf-8", + // The handled-piece loader resolves .mjs and .lisp under an @handle. + routable: true, + blank: (name) => `// ${name}, ${stamp()} +// A blank Aesthetic Computer piece. + +function paint({ wipe, ink, screen }) { + wipe(70, 50, 100); + ink(255, 100, 255).write("${name}", { center: "xy" }); + return false; // Painted once; return true to keep painting. +} + +export { paint }; +`, + }, + lisp: { + id: "lisp", + label: "kidlisp", + extension: ".lisp", + mime: "text/x-lisp; charset=utf-8", + routable: true, + blank: (name) => `; ${name}, ${stamp()} +(wipe "purple") +(ink "pink") +(write "${name}" 6 6) +`, + }, + lua: { + id: "lua", + label: "lua", + extension: ".lua", + mime: "text/x-lua; charset=utf-8", + // Live pushes run, but aesthetic.computer/@handle/ only resolves + // .mjs and .lisp today, so a published .lua has no front door yet. + routable: false, + blank: (name) => `-- ${name}, ${stamp()} + +function paint() + wipe(70, 50, 100) + ink(255, 100, 255) + write("${name}", 6, 6) +end +`, + }, +}; + +export const DEFAULT_RUNTIME = "mjs"; + +function stamp(date = new Date()) { + const pad = (value) => String(value).padStart(2, "0"); + return [ + String(date.getFullYear()).slice(2), + pad(date.getMonth() + 1), + pad(date.getDate()), + ].join("."); +} + +export function runtimeIds() { + return Object.keys(RUNTIMES); +} + +export function runtimeFor(id) { + const runtime = RUNTIMES[String(id || "").replace(/^\./, "").toLowerCase()]; + if (!runtime) { + throw new Error(`unknown runtime "${id}" — try ${runtimeIds().join(", ")}`); + } + return runtime; +} + +export function runtimeForExtension(extension) { + const wanted = String(extension || "").toLowerCase(); + return Object.values(RUNTIMES).find((runtime) => runtime.extension === wanted) || null; +} diff --git a/src/tui.mjs b/src/tui.mjs index 3bcd862c69..6a75238932 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -4,8 +4,11 @@ import path from "node:path"; import process from "node:process"; import { ACSession } from "./ac-session.mjs"; import { AppServer } from "./app-server.mjs"; +import { LivePiece } from "./live.mjs"; import { publishPiece } from "./publish.mjs"; +import { qrBlock } from "./qr.mjs"; import { cleanText, renderFrame } from "./render.mjs"; +import { DEFAULT_RUNTIME, runtimeIds } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; const arguments_ = process.argv.slice(2); @@ -16,9 +19,11 @@ const option = (name) => { const cwd = path.resolve(option("--cwd") || process.cwd()); const resumeThreadId = option("--resume"); const initialPrompt = option("--prompt"); -const PIECE_EXTENSIONS = new Set([".mjs", ".lisp"]); const session = new ACSession(); +// Every session opens on a new blank piece with a random name. It is a real +// file in the workspace, and every edit is pushed to whatever scanned the QR. +const live = new LivePiece({ cwd, runtime: option("--runtime") || DEFAULT_RUNTIME }); const state = { workspace: cwd, mode: "remote", @@ -31,7 +36,8 @@ const state = { approval: null, account: session.label(), piece: "", - pieceFile: "", + qr: null, + showQr: true, entries: [ { id: "privacy", @@ -49,6 +55,8 @@ function developerInstructions() { return [ "You are running inside Aesthetic Code, a terminal interface for Aesthetic Computer (AC) work.", account, + `This session's piece is ${live.file} (${live.runtime.label}). It already exists as a blank piece. Edit that file unless the user asks for something else.`, + "Every save of that file is pushed live to a phone that scanned the interface's QR code, so small frequent edits are better than one big rewrite.", "Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.", "A piece is live only after the user runs the Aesthetic Code command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", "When you finish a piece, end with the exact /publish command for the user to run. Never tell the user to visit a route that has not been published.", @@ -102,6 +110,7 @@ function finish(code = 0) { if (closing) return; closing = true; session.unwatch(); + live.cleanup(); slabSession.close(); engine.close(); process.stdin.setRawMode(false); @@ -114,13 +123,22 @@ function errorText(error) { return cleanText(error?.message || error || "unknown error"); } -// Track the piece under work from the files the agent touches. +// Track the piece under work from the files the agent touches. The QR code +// addresses the channel rather than the file, so it stays valid across a +// retarget; only the name in the header changes. function notePiece(file) { if (!file) return; - const resolved = path.resolve(cwd, file); - if (!PIECE_EXTENSIONS.has(path.extname(resolved).toLowerCase())) return; - state.pieceFile = resolved; - state.piece = path.basename(resolved, path.extname(resolved)); + if (live.retarget(file)) live.watch(liveError); + state.piece = `${live.slug}${live.runtime.extension}`; +} + +function liveError(error) { + addEntry("error", `Live push failed: ${errorText(error)}`); + redraw(); +} + +function refreshQr() { + state.qr = state.showQr ? qrBlock(live.scanUrl) : null; } function itemSummary(item) { @@ -329,7 +347,7 @@ function commandLogout() { } async function commandPublish(argumentText) { - const [file = state.pieceFile, slug = ""] = argumentText.split(/\s+/).filter(Boolean); + const [file = live.file, slug = ""] = argumentText.split(/\s+/).filter(Boolean); if (!file) { addEntry("error", "Usage: /publish [slug] — no piece has been touched yet."); return redraw(); @@ -373,7 +391,7 @@ async function submitInput() { if (command === "/help") { addEntry( "notice", - "/login · /logout · /whoami · /publish [slug] · /piece [name] · /new · /clear · /quit ctrl-c interrupts a running turn", + "/login · /logout · /whoami · /publish [file] · /piece [name] · /runtime [id] · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -386,12 +404,53 @@ async function submitInput() { } if (command === "/publish") return commandPublish(rest); if (command === "/piece") { - if (rest) notePiece(rest.endsWith(".mjs") || rest.endsWith(".lisp") ? rest : `${rest}.mjs`); - else { - state.piece = ""; - state.pieceFile = ""; + if (rest) { + try { + live.rename(rest.split(/\s+/)[0]); + live.watch(liveError); + state.piece = `${live.slug}${live.runtime.extension}`; + addEntry("notice", `Working on ${live.file}`); + } catch (error) { + addEntry("error", errorText(error)); + } + } else { + addEntry("notice", `Working on ${live.file}`); } - addEntry("notice", state.piece ? `Working on ${state.piece}` : "No current piece"); + return redraw(); + } + if (command === "/runtime") { + if (!rest) { + addEntry("notice", `${live.runtime.label} · runtimes: ${runtimeIds().join(", ")}`); + return redraw(); + } + try { + live.rename(live.slug, rest.split(/\s+/)[0]); + live.watch(liveError); + state.piece = `${live.slug}${live.runtime.extension}`; + addEntry("notice", `${live.runtime.label} · ${live.file}`); + if (!live.runtime.routable) { + addEntry("notice", `${live.runtime.label} runs live but has no @handle route yet`); + } + } catch (error) { + addEntry("error", errorText(error)); + } + return redraw(); + } + if (command === "/qr") { + state.showQr = !state.showQr; + refreshQr(); + addEntry("notice", state.showQr ? live.scanUrl : "QR hidden"); + return redraw(); + } + if (command === "/live") { + addEntry("notice", `Pushing ${live.file} to ${live.scanUrl}`); + live.push().then( + () => { + addEntry("notice", `Pushed · ${live.pushes} total`); + redraw(); + }, + (error) => liveError(error), + ); return redraw(); } if (command === "/new") { @@ -544,6 +603,12 @@ engine.on("fatal", (error) => { } }); +// Mint this session's blank piece and the QR code that opens it on a phone. +live.create(); +live.watch(liveError); +state.piece = `${live.slug}${live.runtime.extension}`; +refreshQr(); + redraw(); try { const connection = await engine.connect(); @@ -554,6 +619,8 @@ try { if (!session.signedIn) { addEntry("notice", "Not signed in to Aesthetic Computer · /login to publish under your @handle"); } + addEntry("notice", `${live.slug}${live.runtime.extension} · scan the code or open ${live.scanUrl}`); + live.push().catch(() => {}); redraw(); if (initialPrompt) { replaceInput(initialPrompt); diff --git a/src/vendor/qr.mjs b/src/vendor/qr.mjs new file mode 100644 index 0000000000..675d068e62 --- /dev/null +++ b/src/vendor/qr.mjs @@ -0,0 +1,985 @@ +// Vendored from @akamfoad/qr (MIT), the same encoder Aesthetic Computer uses +// for `share`. Unmodified except for this header. +// src/lib/mode.ts +var mode_default = { + MODE_NUMBER: 1 << 0, + MODE_ALPHA_NUM: 1 << 1, + MODE_8BIT_BYTE: 1 << 2, + MODE_KANJI: 1 << 3 +}; + +// src/lib/8BitByte.ts +var QR8bitByte = class { + mode; + data; + constructor(data) { + this.mode = mode_default.MODE_8BIT_BYTE; + this.data = data; + } + getLength() { + return this.data.length; + } + // FIXME? + write(buffer) { + for (let i = 0; i < this.data.length; i++) { + buffer.put(this.data.charCodeAt(i), 8); + } + } +}; + +// src/lib/ErrorCorrectLevel.ts +var ErrorCorrectLevel = { + /** + * Allows recovery of up to 7% data loss + */ + L: 1, + /** + * Allows recovery of up to 15% data loss + */ + M: 0, + /** + * Allows recovery of up to 25% data loss + */ + Q: 3, + /** + * Allows recovery of up to 30% data loss + */ + H: 2 +}; + +// src/lib/RSBlock.ts +var QRRSBlock = class _QRRSBlock { + totalCount; + dataCount; + constructor(totalCount, dataCount) { + this.totalCount = totalCount; + this.dataCount = dataCount; + } + static RS_BLOCK_TABLE = [ + // L + // M + // Q + // H + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12], + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + static getRSBlocks(typeNumber, errorCorrectLevel) { + const rsBlock = _QRRSBlock.getRsBlockTable(typeNumber, errorCorrectLevel); + if (rsBlock == void 0) { + throw new Error( + "bad rs block @ typeNumber:" + typeNumber + "/errorCorrectLevel:" + errorCorrectLevel + ); + } + const length = rsBlock.length / 3; + const list = []; + for (let i = 0; i < length; i++) { + const count = rsBlock[i * 3 + 0]; + const totalCount = rsBlock[i * 3 + 1]; + const dataCount = rsBlock[i * 3 + 2]; + for (let j = 0; j < count; j++) { + list.push(new _QRRSBlock(totalCount, dataCount)); + } + } + return list; + } + static getRsBlockTable(typeNumber, errorCorrectLevel) { + switch (errorCorrectLevel) { + case ErrorCorrectLevel.L: + return _QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case ErrorCorrectLevel.M: + return _QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case ErrorCorrectLevel.Q: + return _QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case ErrorCorrectLevel.H: + return _QRRSBlock.RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default: + return void 0; + } + } +}; + +// src/lib/BitBuffer.ts +var QRBitBuffer = class { + buffer; + length; + constructor() { + this.buffer = []; + this.length = 0; + } + get(index) { + const bufIndex = Math.floor(index / 8); + return (this.buffer[bufIndex] >>> 7 - index % 8 & 1) == 1; + } + put(num, length) { + for (let i = 0; i < length; i++) { + this.putBit((num >>> length - i - 1 & 1) == 1); + } + } + getLengthInBits() { + return this.length; + } + putBit(bit) { + const bufIndex = Math.floor(this.length / 8); + if (this.buffer.length <= bufIndex) { + this.buffer.push(0); + } + if (bit) { + this.buffer[bufIndex] |= 128 >>> this.length % 8; + } + this.length++; + } +}; + +// src/lib/math.ts +var QRMath = { + glog: function(n) { + if (n < 1) { + throw new Error("glog(" + n + ")"); + } + return QRMath.LOG_TABLE[n]; + }, + gexp: function(n) { + while (n < 0) { + n += 255; + } + while (n >= 256) { + n -= 255; + } + return QRMath.EXP_TABLE[n]; + }, + EXP_TABLE: new Array(256), + LOG_TABLE: new Array(256) +}; +for (let i = 0; i < 8; i++) { + QRMath.EXP_TABLE[i] = 1 << i; +} +for (let i = 8; i < 256; i++) { + QRMath.EXP_TABLE[i] = QRMath.EXP_TABLE[i - 4] ^ QRMath.EXP_TABLE[i - 5] ^ QRMath.EXP_TABLE[i - 6] ^ QRMath.EXP_TABLE[i - 8]; +} +for (let i = 0; i < 255; i++) { + QRMath.LOG_TABLE[QRMath.EXP_TABLE[i]] = i; +} +var math_default = QRMath; + +// src/lib/Polynomial.ts +var QRPolynomial = class _QRPolynomial { + num; + constructor(num, shift) { + if (num.length == void 0) { + throw new Error(num.length + "/" + shift); + } + let offset = 0; + while (offset < num.length && num[offset] == 0) { + offset++; + } + this.num = new Array(num.length - offset + shift); + for (let i = 0; i < num.length - offset; i++) { + this.num[i] = num[i + offset]; + } + } + get(index) { + return this.num[index]; + } + getLength() { + return this.num.length; + } + multiply(e) { + const num = new Array(this.getLength() + e.getLength() - 1); + for (let i = 0; i < this.getLength(); i++) { + for (let j = 0; j < e.getLength(); j++) { + num[i + j] ^= math_default.gexp(math_default.glog(this.get(i)) + math_default.glog(e.get(j))); + } + } + return new _QRPolynomial(num, 0); + } + mod(e) { + if (this.getLength() - e.getLength() < 0) { + return this; + } + const ratio = math_default.glog(this.get(0)) - math_default.glog(e.get(0)); + const num = new Array(this.getLength()); + for (let i = 0; i < this.getLength(); i++) { + num[i] = this.get(i); + } + for (let i = 0; i < e.getLength(); i++) { + num[i] ^= math_default.gexp(math_default.glog(e.get(i)) + ratio); + } + return new _QRPolynomial(num, 0).mod(e); + } +}; + +// src/lib/util.ts +var QRMaskPattern = { + PATTERN000: 0, + PATTERN001: 1, + PATTERN010: 2, + PATTERN011: 3, + PATTERN100: 4, + PATTERN101: 5, + PATTERN110: 6, + PATTERN111: 7 +}; +var QRUtil = { + PATTERN_POSITION_TABLE: [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ], + G15: 1 << 10 | 1 << 8 | 1 << 5 | 1 << 4 | 1 << 2 | 1 << 1 | 1 << 0, + G18: 1 << 12 | 1 << 11 | 1 << 10 | 1 << 9 | 1 << 8 | 1 << 5 | 1 << 2 | 1 << 0, + G15_MASK: 1 << 14 | 1 << 12 | 1 << 10 | 1 << 4 | 1 << 1, + getBCHTypeInfo: function(data) { + let d = data << 10; + while (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G15) >= 0) { + d ^= QRUtil.G15 << QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G15); + } + return (data << 10 | d) ^ QRUtil.G15_MASK; + }, + getBCHTypeNumber: function(data) { + let d = data << 12; + while (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G18) >= 0) { + d ^= QRUtil.G18 << QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G18); + } + return data << 12 | d; + }, + getBCHDigit: function(data) { + let digit = 0; + while (data != 0) { + digit++; + data >>>= 1; + } + return digit; + }, + getPatternPosition: function(typeNumber) { + return QRUtil.PATTERN_POSITION_TABLE[typeNumber - 1]; + }, + getMask: function(maskPattern, i, j) { + switch (maskPattern) { + case QRMaskPattern.PATTERN000: + return (i + j) % 2 == 0; + case QRMaskPattern.PATTERN001: + return i % 2 == 0; + case QRMaskPattern.PATTERN010: + return j % 3 == 0; + case QRMaskPattern.PATTERN011: + return (i + j) % 3 == 0; + case QRMaskPattern.PATTERN100: + return (Math.floor(i / 2) + Math.floor(j / 3)) % 2 == 0; + case QRMaskPattern.PATTERN101: + return i * j % 2 + i * j % 3 == 0; + case QRMaskPattern.PATTERN110: + return (i * j % 2 + i * j % 3) % 2 == 0; + case QRMaskPattern.PATTERN111: + return (i * j % 3 + (i + j) % 2) % 2 == 0; + default: + throw new Error("bad maskPattern:" + maskPattern); + } + }, + getErrorCorrectPolynomial: function(errorCorrectLength) { + let a = new QRPolynomial([1], 0); + for (let i = 0; i < errorCorrectLength; i++) { + a = a.multiply(new QRPolynomial([1, math_default.gexp(i)], 0)); + } + return a; + }, + getLengthInBits: function(mode, type) { + if (1 <= type && type < 10) { + switch (mode) { + case mode_default.MODE_NUMBER: + return 10; + case mode_default.MODE_ALPHA_NUM: + return 9; + case mode_default.MODE_8BIT_BYTE: + return 8; + case mode_default.MODE_KANJI: + return 8; + default: + throw new Error("mode:" + mode); + } + } else if (type < 27) { + switch (mode) { + case mode_default.MODE_NUMBER: + return 12; + case mode_default.MODE_ALPHA_NUM: + return 11; + case mode_default.MODE_8BIT_BYTE: + return 16; + case mode_default.MODE_KANJI: + return 10; + default: + throw new Error("mode:" + mode); + } + } else if (type < 41) { + switch (mode) { + case mode_default.MODE_NUMBER: + return 14; + case mode_default.MODE_ALPHA_NUM: + return 13; + case mode_default.MODE_8BIT_BYTE: + return 16; + case mode_default.MODE_KANJI: + return 12; + default: + throw new Error("mode:" + mode); + } + } else { + throw new Error("type:" + type); + } + }, + getLostPoint: function(qrCode) { + const moduleCount = qrCode.getModuleCount(); + let lostPoint = 0; + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + let sameCount = 0; + const dark = qrCode.isDark(row, col); + for (let r = -1; r <= 1; r++) { + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + for (let c = -1; c <= 1; c++) { + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + if (r == 0 && c == 0) { + continue; + } + if (dark == qrCode.isDark(row + r, col + c)) { + sameCount++; + } + } + } + if (sameCount > 5) { + lostPoint += 3 + sameCount - 5; + } + } + } + for (let row = 0; row < moduleCount - 1; row++) { + for (let col = 0; col < moduleCount - 1; col++) { + let count = 0; + if (qrCode.isDark(row, col)) + count++; + if (qrCode.isDark(row + 1, col)) + count++; + if (qrCode.isDark(row, col + 1)) + count++; + if (qrCode.isDark(row + 1, col + 1)) + count++; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount - 6; col++) { + if (qrCode.isDark(row, col) && !qrCode.isDark(row, col + 1) && qrCode.isDark(row, col + 2) && qrCode.isDark(row, col + 3) && qrCode.isDark(row, col + 4) && !qrCode.isDark(row, col + 5) && qrCode.isDark(row, col + 6)) { + lostPoint += 40; + } + } + } + for (let col = 0; col < moduleCount; col++) { + for (let row = 0; row < moduleCount - 6; row++) { + if (qrCode.isDark(row, col) && !qrCode.isDark(row + 1, col) && qrCode.isDark(row + 2, col) && qrCode.isDark(row + 3, col) && qrCode.isDark(row + 4, col) && !qrCode.isDark(row + 5, col) && qrCode.isDark(row + 6, col)) { + lostPoint += 40; + } + } + } + let darkCount = 0; + for (let col = 0; col < moduleCount; col++) { + for (let row = 0; row < moduleCount; row++) { + if (qrCode.isDark(row, col)) { + darkCount++; + } + } + } + const ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + return lostPoint; + } +}; +var util_default = QRUtil; + +// src/lib/QRCode.ts +var QRCode = class _QRCode { + typeNumber; + errorCorrectLevel; + modules; + moduleCount; + dataCache; + dataList; + constructor(typeNumber, errorCorrectLevel) { + this.typeNumber = typeNumber; + this.errorCorrectLevel = errorCorrectLevel; + this.modules = null; + this.moduleCount = 0; + this.dataCache = null; + this.dataList = []; + } + // TODO data may be anything, but we start with string + addData(data) { + const newData = new QR8bitByte(data); + this.dataList.push(newData); + this.dataCache = null; + } + isDark(row, col) { + if (row < 0 || this.moduleCount <= row || col < 0 || this.moduleCount <= col) { + throw new Error(row + "," + col); + } + if (this.modules === null) { + throw new Error("this.modules is null"); + } + return this.modules[row][col]; + } + getModuleCount() { + return this.moduleCount; + } + make() { + if (this.typeNumber < 1) { + let typeNumber = 1; + for (typeNumber = 1; typeNumber < 40; typeNumber++) { + const rsBlocks = QRRSBlock.getRSBlocks( + typeNumber, + this.errorCorrectLevel + ); + const buffer = new QRBitBuffer(); + let totalDataCount = 0; + for (let i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + for (let i = 0; i < this.dataList.length; i++) { + const data = this.dataList[i]; + buffer.put(data.mode, 4); + buffer.put( + data.getLength(), + util_default.getLengthInBits(data.mode, typeNumber) + ); + data.write(buffer); + } + if (buffer.getLengthInBits() <= totalDataCount * 8) + break; + } + this.typeNumber = typeNumber; + } + this.makeImpl(false, this.getBestMaskPattern()); + } + makeImpl(test, maskPattern) { + this.moduleCount = this.typeNumber * 4 + 17; + this.modules = new Array(this.moduleCount); + for (let row = 0; row < this.moduleCount; row++) { + this.modules[row] = new Array(this.moduleCount); + for (let col = 0; col < this.moduleCount; col++) { + this.modules[row][col] = null; + } + } + this.setupPositionProbePattern(0, 0); + this.setupPositionProbePattern(this.moduleCount - 7, 0); + this.setupPositionProbePattern(0, this.moduleCount - 7); + this.setupPositionAdjustPattern(); + this.setupTimingPattern(); + this.setupTypeInfo(test, maskPattern); + if (this.typeNumber >= 7) { + this.setupTypeNumber(test); + } + if (this.dataCache == null) { + this.dataCache = _QRCode.createData( + this.typeNumber, + this.errorCorrectLevel, + this.dataList + ); + } + this.mapData(this.dataCache, maskPattern); + } + setupPositionProbePattern(row, col) { + for (let r = -1; r <= 7; r++) { + if (row + r <= -1 || this.moduleCount <= row + r) + continue; + for (let c = -1; c <= 7; c++) { + if (col + c <= -1 || this.moduleCount <= col + c) + continue; + if (0 <= r && r <= 6 && (c == 0 || c == 6) || 0 <= c && c <= 6 && (r == 0 || r == 6) || 2 <= r && r <= 4 && 2 <= c && c <= 4) { + if (this.modules === null) { + throw new Error("this.modules is null"); + } + this.modules[row + r][col + c] = true; + } else { + if (this.modules === null) { + throw new Error("this.modules is null"); + } + this.modules[row + r][col + c] = false; + } + } + } + } + getBestMaskPattern() { + let minLostPoint = 0; + let pattern = 0; + for (let i = 0; i < 8; i++) { + this.makeImpl(true, i); + const lostPoint = util_default.getLostPoint(this); + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + return pattern; + } + setupTimingPattern() { + if (this.modules === null) { + throw new Error("this.modules is null"); + } + for (let r = 8; r < this.moduleCount - 8; r++) { + if (this.modules[r][6] != null) { + continue; + } + this.modules[r][6] = r % 2 == 0; + } + for (let c = 8; c < this.moduleCount - 8; c++) { + if (this.modules[6][c] != null) { + continue; + } + this.modules[6][c] = c % 2 == 0; + } + } + setupPositionAdjustPattern() { + if (this.modules === null) { + throw new Error("this.modules is null"); + } + const pos = util_default.getPatternPosition(this.typeNumber); + for (let i = 0; i < pos.length; i++) { + for (let j = 0; j < pos.length; j++) { + const row = pos[i]; + const col = pos[j]; + if (this.modules[row][col] != null) { + continue; + } + for (let r = -2; r <= 2; r++) { + for (let c = -2; c <= 2; c++) { + if (r == -2 || r == 2 || c == -2 || c == 2 || r == 0 && c == 0) { + this.modules[row + r][col + c] = true; + } else { + this.modules[row + r][col + c] = false; + } + } + } + } + } + } + setupTypeNumber(test) { + if (this.modules === null) { + throw new Error("this.modules is null"); + } + const bits = util_default.getBCHTypeNumber(this.typeNumber); + for (let i = 0; i < 18; i++) { + const mod = !test && (bits >> i & 1) == 1; + this.modules[Math.floor(i / 3)][i % 3 + this.moduleCount - 8 - 3] = mod; + } + for (let i = 0; i < 18; i++) { + const mod = !test && (bits >> i & 1) == 1; + this.modules[i % 3 + this.moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + } + setupTypeInfo(test, maskPattern) { + if (this.modules === null) { + throw new Error("this.modules is null"); + } + const data = this.errorCorrectLevel << 3 | maskPattern; + const bits = util_default.getBCHTypeInfo(data); + for (let i = 0; i < 15; i++) { + const mod = !test && (bits >> i & 1) == 1; + if (i < 6) { + this.modules[i][8] = mod; + } else if (i < 8) { + this.modules[i + 1][8] = mod; + } else { + this.modules[this.moduleCount - 15 + i][8] = mod; + } + } + for (let i = 0; i < 15; i++) { + const mod = !test && (bits >> i & 1) == 1; + if (i < 8) { + this.modules[8][this.moduleCount - i - 1] = mod; + } else if (i < 9) { + this.modules[8][15 - i - 1 + 1] = mod; + } else { + this.modules[8][15 - i - 1] = mod; + } + } + this.modules[this.moduleCount - 8][8] = !test; + } + mapData(data, maskPattern) { + if (this.modules === null) { + throw new Error("this.modules is null"); + } + let inc = -1; + let row = this.moduleCount - 1; + let bitIndex = 7; + let byteIndex = 0; + for (let col = this.moduleCount - 1; col > 0; col -= 2) { + if (col == 6) + col--; + while (true) { + for (let c = 0; c < 2; c++) { + if (this.modules[row][col - c] == null) { + let dark = false; + if (byteIndex < data.length) { + dark = (data[byteIndex] >>> bitIndex & 1) == 1; + } + const mask = util_default.getMask(maskPattern, row, col - c); + if (mask) { + dark = !dark; + } + this.modules[row][col - c] = dark; + bitIndex--; + if (bitIndex == -1) { + byteIndex++; + bitIndex = 7; + } + } + } + row += inc; + if (row < 0 || this.moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + } + static PAD0 = 236; + static PAD1 = 17; + static createData(typeNumber, errorCorrectLevel, dataList) { + const rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectLevel); + const buffer = new QRBitBuffer(); + for (let i = 0; i < dataList.length; i++) { + const data = dataList[i]; + buffer.put(data.mode, 4); + buffer.put(data.getLength(), util_default.getLengthInBits(data.mode, typeNumber)); + data.write(buffer); + } + let totalDataCount = 0; + for (let i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw new Error( + "code length overflow. (" + buffer.getLengthInBits() + ">" + totalDataCount * 8 + ")" + ); + } + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + while (true) { + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(_QRCode.PAD0, 8); + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(_QRCode.PAD1, 8); + } + return _QRCode.createBytes(buffer, rsBlocks); + } + static createBytes(buffer, rsBlocks) { + let offset = 0; + let maxDcCount = 0; + let maxEcCount = 0; + const dcdata = new Array(rsBlocks.length); + const ecdata = new Array(rsBlocks.length); + for (let r = 0; r < rsBlocks.length; r++) { + const dcCount = rsBlocks[r].dataCount; + const ecCount = rsBlocks[r].totalCount - dcCount; + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + dcdata[r] = new Array(dcCount); + for (let i = 0; i < dcdata[r].length; i++) { + dcdata[r][i] = 255 & buffer.buffer[i + offset]; + } + offset += dcCount; + const rsPoly = util_default.getErrorCorrectPolynomial(ecCount); + const rawPoly = new QRPolynomial(dcdata[r], rsPoly.getLength() - 1); + const modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (let i = 0; i < ecdata[r].length; i++) { + const modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = modIndex >= 0 ? modPoly.get(modIndex) : 0; + } + } + let totalCodeCount = 0; + for (let i = 0; i < rsBlocks.length; i++) { + totalCodeCount += rsBlocks[i].totalCount; + } + const data = new Array(totalCodeCount); + let index = 0; + for (let i = 0; i < maxDcCount; i++) { + for (let r = 0; r < rsBlocks.length; r++) { + if (i < dcdata[r].length) { + data[index++] = dcdata[r][i]; + } + } + } + for (let i = 0; i < maxEcCount; i++) { + for (let r = 0; r < rsBlocks.length; r++) { + if (i < ecdata[r].length) { + data[index++] = ecdata[r][i]; + } + } + } + return data; + } +}; + +// src/index.ts +var qrcode = (data, opt) => { + opt = opt || {}; + const qr = new QRCode( + opt.typeNumber || -1, + opt.errorCorrectLevel || ErrorCorrectLevel.H + ); + qr.addData(data); + qr.make(); + return qr; +}; +export { + ErrorCorrectLevel, + QRCode, + qrcode +}; diff --git a/test/cli.sh b/test/cli.sh index 3fedea51d8..41a168a1cb 100755 --- a/test/cli.sh +++ b/test/cli.sh @@ -31,7 +31,7 @@ assert_contains() { } output="$($CLI --version)" -assert_contains "$output" 'Aesthetic Code 0.3.0' +assert_contains "$output" 'Aesthetic Code 0.4.0' output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" assert_contains "$output" 'interface=aesthetic-code' @@ -72,6 +72,17 @@ if HOME="$FAKE_HOME" AESTHETIC_CODE_DRY_RUN=1 "$CLI" publish >/dev/null 2>&1; th exit 1 fi +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --runtime lisp "$WORK_DIR")" +assert_contains "$output" 'runtime=lisp' + +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" +assert_contains "$output" 'runtime=mjs' + +if AESTHETIC_CODE_DRY_RUN=1 "$CLI" --runtime rust "$WORK_DIR" >/dev/null 2>&1; then + printf 'Expected an unknown runtime to fail.\n' >&2 + exit 1 +fi + if AESTHETIC_CODE_DRY_RUN=1 "$CLI" claude >/dev/null 2>&1; then printf 'Expected the removed provider shortcut to fail.\n' >&2 exit 1 diff --git a/test/live.test.mjs b/test/live.test.mjs new file mode 100644 index 0000000000..445c208a3c --- /dev/null +++ b/test/live.test.mjs @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { LivePiece, pieceDirectory } from "../src/live.mjs"; +import { randomChannel, randomSlug } from "../src/names.mjs"; +import { qrBlock } from "../src/qr.mjs"; +import { runtimeFor, runtimeForExtension, runtimeIds } from "../src/runtimes.mjs"; + +async function workspace(context) { + const root = await mkdtemp(join(tmpdir(), "aesthetic-code-live-")); + context.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +const ok = () => new Response("Reloaded!", { status: 200 }); + +test("names are pronounceable and channels stay short enough to scan", () => { + for (let attempt = 0; attempt < 200; attempt += 1) { + assert.match(randomSlug(), /^[a-z]{4,6}$/); + assert.match(randomChannel(), /^[A-Za-z0-9_-]{8}$/); + } + // A version-3 QR is the largest that fits a terminal corner: 33 columns. + const block = qrBlock(`aesthetic.computer/prompt~channel~${randomChannel()}~!autorun`); + assert.equal(block.width, 33); + assert.equal(block.height, 17); +}); + +// Read the rendered block back into a grid of dark modules. A code that is +// transposed, mirrored, or off by a row still "looks like" a QR code in a +// terminal, so compare against the encoder rather than trusting the picture. +function decodeBlock(block) { + const grid = []; + for (const line of block.lines) { + const cells = line.split("\u2580").slice(0, -1); + const top = []; + const bottom = []; + for (const cell of cells) { + const [, fg, bg] = cell.match(/38;5;(\d+)m\x1b\[48;5;(\d+)m$/); + top.push(fg === "0"); + bottom.push(bg === "0"); + } + grid.push(top, bottom); + } + return grid; +} + +test("the rendered code matches the encoder module for module", async () => { + const { qrcode, ErrorCorrectLevel } = await import("../src/vendor/qr.mjs"); + const url = "aesthetic.computer/prompt~channel~Ab0-_9Zz~!autorun"; + const quiet = 2; + const modules = qrcode(url, { errorCorrectLevel: ErrorCorrectLevel.L }).modules; + const grid = decodeBlock(qrBlock(url, { quiet })); + + for (let row = 0; row < modules.length; row += 1) { + for (let column = 0; column < modules.length; column += 1) { + assert.equal( + grid[row + quiet][column + quiet], + Boolean(modules[row][column]), + `module ${column},${row} does not match the encoder`, + ); + } + } + + // The quiet zone has to be light or a camera never finds the code. + for (let column = 0; column < grid[0].length; column += 1) { + assert.equal(grid[0][column], false); + assert.equal(grid[1][column], false); + } + + // A finder pattern is a 7x7 target: dark ring, light ring, dark 3x3 core. + // Three corners carry one and the fourth never does, which is how a scanner + // works out the code's orientation. + const isFinder = (left, top) => { + for (let y = 0; y < 7; y += 1) { + for (let x = 0; x < 7; x += 1) { + const ring = x === 0 || y === 0 || x === 6 || y === 6; + const core = x >= 2 && x <= 4 && y >= 2 && y <= 4; + if (grid[top + quiet + y][left + quiet + x] !== (ring || core)) return false; + } + } + return true; + }; + const last = modules.length - 7; + assert.equal(isFinder(0, 0), true, "top-left finder"); + assert.equal(isFinder(last, 0), true, "top-right finder"); + assert.equal(isFinder(0, last), true, "bottom-left finder"); + assert.equal(isFinder(last, last), false, "no finder in the bottom-right"); +}); + +test("every runtime carries a blank that names itself", () => { + assert.deepEqual(runtimeIds(), ["mjs", "lisp", "lua"]); + for (const id of runtimeIds()) { + const runtime = runtimeFor(id); + assert.match(runtime.blank("movika"), /movika/); + assert.equal(runtimeForExtension(runtime.extension), runtime); + } + assert.equal(runtimeFor(".LISP").id, "lisp"); + assert.equal(runtimeFor("mjs").routable, true); + assert.equal(runtimeFor("lua").routable, false); + assert.throws(() => runtimeFor("rust"), /unknown runtime/); +}); + +test("a session mints a blank piece, pushes it, and cleans up after itself", async (context) => { + const root = await workspace(context); + const calls = []; + const live = new LivePiece({ + cwd: root, + slug: "movika", + channel: "Ab0-_9Zz", + fetch: async (url, options) => { + calls.push({ url, ...JSON.parse(options.body) }); + return ok(); + }, + }); + + assert.equal(live.file, join(root, "movika.mjs")); + assert.equal(live.scanUrl, "aesthetic.computer/prompt~channel~Ab0-_9Zz~!autorun"); + assert.equal(live.publishedUrl("jeffrey"), "https://aesthetic.computer/@jeffrey/movika"); + assert.equal(live.publishedUrl(""), ""); + + live.create(); + assert.equal(existsSync(live.file), true); + assert.equal(live.pristine, true); + assert.match(await readFile(live.file, "utf8"), /movika/); + + assert.equal(await live.push(), true); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://aesthetic.computer/run"); + assert.equal(calls[0].piece, "movika"); + assert.equal(calls[0].codeChannel, "Ab0-_9Zz"); + assert.match(calls[0].source, /movika/); + + // An untouched blank leaves nothing behind. + assert.equal(live.cleanup(), true); + assert.equal(existsSync(live.file), false); +}); + +test("an edited piece is never deleted on the way out", async (context) => { + const root = await workspace(context); + const live = new LivePiece({ cwd: root, slug: "movika", fetch: async () => ok() }); + live.create(); + await writeFile(live.file, "export function paint() {}\n"); + assert.equal(live.cleanup(), false); + assert.equal(existsSync(live.file), true); +}); + +test("renaming and retargeting follow the piece without changing the channel", async (context) => { + const root = await workspace(context); + const live = new LivePiece({ cwd: root, slug: "movika", channel: "Ab0-_9Zz", fetch: async () => ok() }); + live.create(); + const scan = live.scanUrl; + + live.rename("smiley", "lisp"); + assert.equal(live.file, join(root, "smiley.lisp")); + assert.equal(live.runtime.label, "kidlisp"); + assert.equal(existsSync(join(root, "movika.mjs")), false, "the untouched blank moved rather than piling up"); + assert.match(await readFile(live.file, "utf8"), /\(wipe "purple"\)/); + assert.equal(live.scanUrl, scan, "the QR stays valid across a rename"); + + assert.equal(live.retarget(join(root, "other.lisp")), true); + assert.equal(live.slug, "other"); + assert.equal(live.retarget(join(root, "notes.txt")), false, "only piece files are tracked"); + assert.equal(live.slug, "other"); + assert.throws(() => live.rename("bad name"), /letters, digits/); +}); + +test("pieces land in the disks folder when the workspace is the AC repository", async (context) => { + const root = await workspace(context); + assert.equal(pieceDirectory(root), root); + const disks = join(root, "system", "public", "aesthetic.computer", "disks"); + await rm(disks, { recursive: true, force: true }); + const { mkdir } = await import("node:fs/promises"); + await mkdir(disks, { recursive: true }); + assert.equal(pieceDirectory(root), disks); + const live = new LivePiece({ cwd: root, slug: "movika", fetch: async () => ok() }); + assert.equal(live.file, join(disks, "movika.mjs")); +}); + +test("a failed push is reported rather than swallowed", async (context) => { + const root = await workspace(context); + const live = new LivePiece({ + cwd: root, + slug: "movika", + fetch: async () => new Response("nope", { status: 500 }), + }); + live.create(); + await assert.rejects(() => live.push(), /HTTP 500/); +}); diff --git a/test/render.test.mjs b/test/render.test.mjs index 48a2c9bbb4..4d8c3ae359 100644 --- a/test/render.test.mjs +++ b/test/render.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { cleanText, renderFrame, wrapText } from "../src/render.mjs"; +import { cleanText, renderFrame, textWidth, wrapText } from "../src/render.mjs"; test("cleans terminal control sequences", () => { assert.equal(cleanText("safe\x1b[2J\x00 text"), "safe text"); @@ -60,6 +60,40 @@ test("shows the signed-in handle and the current piece in the header", () => { assert.match(renderFrame({ workspace: "/p", mode: "remote", status: "ready", entries: [], input: "" }, 60, 12, false), /not signed in/); }); +test("keeps a QR column on the right and narrows the transcript around it", async () => { + const { qrBlock } = await import("../src/qr.mjs"); + const qr = qrBlock("aesthetic.computer/prompt~channel~Ab0-_9Zz~!autorun"); + const state = { + workspace: "/project", + mode: "remote", + status: "ready", + account: "@tester", + piece: "movika.mjs", + input: "", + entries: [{ id: "a", kind: "user", text: "hello" }], + qr, + }; + const wide = renderFrame(state, 100, 26, true); + const rows = wide.split("\n"); + assert.equal(rows.length, 26); + for (const row of rows) assert.equal(textWidth(row), 100, "every row fills the frame exactly"); + assert.equal(rows.at(-6).includes("\u2580"), true, "the code sits on the bottom transcript rows"); + assert.equal(rows[2].includes("\u2580"), false, "and not at the top"); + + // Too small a window, or no colour, drops the code rather than corrupting the frame. + const narrow = renderFrame(state, 50, 26, true); + assert.equal(narrow.includes("\u2580"), false); + for (const row of narrow.split("\n")) assert.equal(textWidth(row), 50); + assert.equal(renderFrame(state, 100, 26, false).includes("\u2580"), false, "NO_COLOR keeps the frame plain"); + + // No window is too small to draw a whole frame in. + for (const columns of [32, 40, 50, 64, 80, 140]) { + for (const row of renderFrame(state, columns, 12, true).split("\n")) { + assert.equal(textWidth(row), columns, `row overflows at ${columns} columns`); + } + } +}); + test("renders approvals inside the interface", () => { const frame = renderFrame( { -- 2.51.2 From fb5d23cf1d83f9f8a92c3609e07edff39cf6d587 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 16:50:33 -0400 Subject: [PATCH 06/18] A Claude bridge, and a QR code that actually opens the piece MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three lanes from one afternoon, all in the live-piece path. **The QR was broken and had shipped unscanned.** It encoded `prompt~channel~~!autorun`, but the prompt hands everything after `prompt~` to itself as one parameter and then re-splits on spaces — so `channel~` arrived as a single token, `channel` ran with no argument, and the phone landed on an empty prompt. The separator has to be `%20`. Verified by decoding the code off the terminal's own cells and opening it. **Processing already existed, under its language's name.** `lua` is L5, the Processing API in Lua; it is now called `processing`, with `js`/`lisp`/`lua`/ `l5` as aliases. Its blank was written in Aesthetic Computer's own vocabulary (`paint`/`wipe`/`ink`), which meant the runtime sniffer never recognised it as Lua and compiled it as JavaScript, where it died. It is real L5 now. **A Claude engine beside the Codex one**, defaulting to `claude-fable-5-1`. `claude --print --input-format stream-json --output-format stream-json` is a subprocess on stdio, structurally the same shape as `codex app-server --stdio`, so the bridge cost no dependencies — package.json still has none. Two flags carry it: `--permission-prompt-tool stdio` is what makes this program the approver rather than having every prompt silently auto-denied, and readiness is the `initialize` control response, not `system/init`, which does not arrive until the first turn. Codex is untouched and still selectable; `/backend` switches engines live without disturbing the piece, channel or QR. The sandbox story is not the same on both sides and the docs say so plainly: Codex runs commands under an OS sandbox with no network, Claude gates them at the approval prompt instead, and an approved command runs with the user's privileges. File tools are confined with `--add-dir`, WebFetch/WebSearch/Task are removed, and `--setting-sources ""` keeps personal allow-lists, hooks and MCP servers out of a session. **The re-announce heartbeat is gone.** It re-sent the source every 4s so a phone scanning at any moment would catch the piece, because the session server relayed `/run` pushes to whoever was already listening and kept no copy. That was a one-line server bug, now fixed and deployed (aesthetic-computer 1bf64b8e84) — including a `send is not defined` typo in the replay branch that had never once run. Verified against production: a push made three seconds before anyone subscribes now reaches the late joiner. A beat per session per four seconds to cover a typo was rent, not architecture. --- README.md | 76 +++++- bin/aesthetic | 59 ++++- docs/local-contract.md | 50 +++- src/app-server.mjs | 6 + src/backends.mjs | 62 +++++ src/claude-server.mjs | 509 ++++++++++++++++++++++++++++++++++++ src/live.mjs | 39 ++- src/runtimes.mjs | 43 ++- src/tui.mjs | 159 ++++++++--- test/claude-server.test.mjs | 158 +++++++++++ test/cli.sh | 20 ++ test/fake-claude-cli.mjs | 97 +++++++ test/live.test.mjs | 82 +++++- test/render.test.mjs | 2 +- 14 files changed, 1279 insertions(+), 83 deletions(-) create mode 100644 src/backends.mjs create mode 100644 src/claude-server.mjs create mode 100644 test/claude-server.test.mjs create mode 100644 test/fake-claude-cli.mjs diff --git a/README.md b/README.md index 3dfeb01e4e..a74ebfe56c 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,9 @@ ac ~/project ``` The interface owns the conversation, streaming, tool activity, interruption, -and approvals. It does not launch a managed provider client. The current engine -bridge uses `codex app-server`; its inference is remote and the TUI labels that -boundary before a prompt is sent. A native local-inference engine can replace -the bridge without changing the interface. +and approvals. It does not launch a managed provider client. Behind it sits an +engine bridge: a subprocess on stdio that the interface drives. A native +local-inference engine can replace the bridge without changing the interface. Each live TUI publishes its own Slab marker, so the menubar and prox ledger can name, focus, wake, close, and track it as `aesthetic-code`, including which @@ -29,8 +28,44 @@ 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|lua]`, `/qr`, `/live`, `/new`, `/clear`, -`/help`, `/quit`. Press `ctrl-c` to interrupt a running turn or exit while idle. +`/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. + +## Engine bridges + +Two bridges ship, and either can drive a session: + +```sh +ac # claude, on claude-fable-5-1 +ac --backend codex # codex app-server +ac --model claude-opus-5 # a different model on the same bridge +``` + +`/backend` and `/model` do the same thing mid-session — both restart the +conversation on the new engine and leave the piece, the channel and the QR code +exactly where they were. `/backend` with no argument says which engine and +model are running. + +The Claude bridge runs `claude --print --input-format stream-json +--output-format stream-json`, the same headless protocol the Claude Agent SDK +speaks, driven directly over a pipe. That is why this repository still has no +dependencies: a subprocess on stdio is the same shape as `codex app-server +--stdio`, and it carries streaming, tool calls and approvals without a package +tree behind it. Each bridge signs in with the vendor CLI's own credentials +already on the machine. + +Approvals come back to this terminal on both bridges: `y` once, `a` for the +session, `n` to deny. Neither bridge inherits the user's own agent +configuration — Codex is pinned to `on-request` approvals and a +`workspace-write` sandbox, and Claude is launched with `--setting-sources ""` +and `--strict-mcp-config` — so nothing but the person watching can approve a +command in a session, and an `a` is never written to a settings file. + +The two are not equivalent on containment. Codex runs commands inside an +operating-system sandbox with the network off; Claude Code has no such sandbox, +so on that bridge the approval prompt is the whole boundary. The difference is +written down in [`docs/local-contract.md`](docs/local-contract.md). ## The session's piece, live on a phone @@ -40,22 +75,40 @@ bottom right of the interface. Scan the code and the piece runs on your phone; every edit the agent makes reaches it a moment later. The link works through the code channel Aesthetic Computer already uses for -live editing. The QR opens `prompt~channel~~!autorun`, which runs the +live editing. The QR opens `prompt~channel%20~!autorun`, which runs the prompt's own `channel` command, and the interface POSTs each save to `/run`, which relays it over Redis and the session server to everything watching that channel. It is the same path the VS Code extension has always used, so nothing new runs on the server. +The encoded space is load-bearing. Aesthetic Computer routes anything beginning +`prompt~` by handing the whole remainder to the prompt as a single parameter, +and the prompt splits its own arguments on spaces: write the channel after a +tilde and it arrives glued to the command name, so `channel` runs with nothing +to join and the phone sits on an empty prompt. + +A push reaches only whoever is already on the channel — nothing keeps a copy of +one — so the interface re-sends the current source every few seconds. That is +what lets the code be scanned at any moment rather than only just after a save; +a phone that arrives mid-session picks the piece up within a beat. + A blank that is never edited is deleted when the session ends, so opening and closing the interface leaves the workspace exactly as it was. In the Aesthetic Computer repository the piece is written to `system/public/aesthetic.computer/ disks/`; anywhere else it lands in the workspace root. `/runtime` switches the language of the piece — `mjs` (JavaScript), `lisp` -(KidLisp), or `lua` — and rewrites the blank. `--runtime` picks it at launch. -Live pushes work for all three; `aesthetic.computer/@handle/` resolves -`.mjs` and `.lisp` today, so a Lua piece runs live but has no published route -yet. +(KidLisp), or `lua` (Processing, via L5) — and rewrites the blank. `--runtime` +picks it at launch, and `processing`, `kidlisp`, `js` and `l5` are accepted as +names for the same three. Live pushes work for all three; +`aesthetic.computer/@handle/` resolves `.mjs` and `.lisp` today, so a +Processing piece runs live but has no published route yet. + +Processing pieces are written in Processing's vocabulary — `setup` and `draw`, +`background`, `fill`, `circle` — not Aesthetic Computer's `paint`. That is also +what keeps them running: a live push carries no file extension, so the client +recognises Lua by reading the source, and only source that opens with a `--` +comment and declares `setup` or `draw` is taken as Lua at all. ## Account and publishing @@ -76,6 +129,7 @@ aesthetic login aesthetic whoami aesthetic publish system/public/aesthetic.computer/disks/smiley.mjs ac --runtime lisp +ac --backend codex ``` ```sh diff --git a/bin/aesthetic b/bin/aesthetic index 498fe7cb3b..18cf349a17 100755 --- a/bin/aesthetic +++ b/bin/aesthetic @@ -3,6 +3,8 @@ set -euo pipefail VERSION="0.4.0" +DEFAULT_BACKEND="${AESTHETIC_CODE_BACKEND:-claude}" +DEFAULT_CLAUDE_MODEL="claude-fable-5-1" script_path="${BASH_SOURCE[0]}" while [[ -L "$script_path" ]]; do script_directory="$(cd "$(dirname "$script_path")" && pwd -P)" @@ -17,7 +19,8 @@ PROJECT_DIR="$(cd "$(dirname "$script_path")/.." && pwd -P)" usage() { cat <<'EOF' -Usage: ac [directory] [--runtime mjs|lisp|lua] +Usage: ac [directory] [--runtime mjs|lisp|processing] + [--backend claude|codex] [--model NAME] aesthetic [directory] aesthetic doctor aesthetic login | logout | whoami @@ -29,6 +32,10 @@ your @handle at https://aesthetic.computer/@handle/slug. Each session opens on a new blank piece and shows a QR code that runs it live on a phone. --runtime picks the language of that piece. + +--backend picks the engine bridge: claude (the default, on +claude-fable-5-1) or codex. --model names the model on that bridge, and +/backend and /model change either one mid-session. EOF } @@ -53,11 +60,14 @@ doctor() { printf 'node: not installed\n' fi - if has_command codex; then - printf 'engine bridge: ready\n' - else - printf 'engine bridge: unavailable (install Codex)\n' - fi + for backend in claude codex; do + if has_command "$backend"; then + printf 'engine bridge %s: ready\n' "$backend" + else + printf 'engine bridge %s: unavailable\n' "$backend" + fi + done + printf 'default engine bridge: %s (%s)\n' "$DEFAULT_BACKEND" "$DEFAULT_CLAUDE_MODEL" if has_command node; then printf 'account: %s\n' "$(node "$PROJECT_DIR/src/cli.mjs" whoami 2>/dev/null || printf 'not signed in')" @@ -88,6 +98,8 @@ target_directory="" resume_thread="" initial_prompt="" runtime="" +backend="" +model="" while [[ $# -gt 0 ]]; do case "$1" in --resume) @@ -102,11 +114,28 @@ while [[ $# -gt 0 ]]; do initial_prompt="$2" shift 2 ;; + --backend|--engine) + [[ $# -ge 2 ]] || fail "--backend requires an engine" + case "$2" in + # backends.mjs resolves the aliases; this is the same set. + claude|codex|anthropic|fable|openai|gpt) backend="$2" ;; + *) fail "unknown backend: $2 (claude, codex)" ;; + esac + shift 2 + ;; + --model) + [[ $# -ge 2 ]] || fail "--model requires a name" + [[ "$2" =~ ^[A-Za-z0-9._@:-]+$ ]] || fail "invalid model name: $2" + model="$2" + shift 2 + ;; --runtime) [[ $# -ge 2 ]] || fail "--runtime requires a language" case "$2" in - mjs|lisp|lua) runtime="$2" ;; - *) fail "unknown runtime: $2 (mjs, lisp, lua)" ;; + # The names people reach for are accepted alongside the file + # extensions; runtimes.mjs resolves the aliases. + mjs|lisp|lua|js|kidlisp|processing|l5) runtime="$2" ;; + *) fail "unknown runtime: $2 (mjs, lisp, processing)" ;; esac shift 2 ;; @@ -132,7 +161,15 @@ target_directory="${target_directory:-$PWD}" target_directory="$(cd "$target_directory" && pwd -P)" has_command node || fail "Node.js is required" -has_command codex || fail "the Aesthetic Code engine bridge is not installed" + +# The bridge is a subprocess, so the one that will be spawned has to be here. +selected_backend="${backend:-$DEFAULT_BACKEND}" +case "$selected_backend" in + anthropic|fable) selected_backend="claude" ;; + openai|gpt) selected_backend="codex" ;; +esac +has_command "$selected_backend" || + fail "the $selected_backend engine bridge is not installed" if [[ "${AESTHETIC_CODE_DRY_RUN:-0}" == "1" ]]; then printf 'interface=aesthetic-code\n' @@ -140,6 +177,8 @@ if [[ "${AESTHETIC_CODE_DRY_RUN:-0}" == "1" ]]; then printf 'resume=%s\n' "$([[ -n "$resume_thread" ]] && printf yes || printf no)" printf 'initial_prompt=%s\n' "$([[ -n "$initial_prompt" ]] && printf yes || printf no)" printf 'runtime=%s\n' "${runtime:-mjs}" + printf 'backend=%s\n' "$selected_backend" + printf 'model=%s\n' "${model:-$([[ "$selected_backend" == claude ]] && printf '%s' "$DEFAULT_CLAUDE_MODEL")}" exit 0 fi @@ -151,4 +190,6 @@ arguments=("--cwd" "$target_directory") if [[ -n "$resume_thread" ]]; then arguments+=("--resume" "$resume_thread"); fi if [[ -n "$initial_prompt" ]]; then arguments+=("--prompt" "$initial_prompt"); fi if [[ -n "$runtime" ]]; then arguments+=("--runtime" "$runtime"); fi +arguments+=("--backend" "$selected_backend") +if [[ -n "$model" ]]; then arguments+=("--model" "$model"); fi exec node "$PROJECT_DIR/src/tui.mjs" "${arguments[@]}" diff --git a/docs/local-contract.md b/docs/local-contract.md index 2823b5baa5..7f12550b7b 100644 --- a/docs/local-contract.md +++ b/docs/local-contract.md @@ -24,21 +24,57 @@ interface action, not an agent tool. ## Live piece boundary The session's piece is pushed to `aesthetic.computer/run` on a private code -channel every time its file changes, so that a phone that scanned the QR code -can run 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. +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 +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. Pushing is the interface's own action, on a file the user can see, and stops -when the session ends. +when the session ends. It is the one thing Aesthetic Code sends without being +asked each time, so it is worth stating plainly: while the interface is open, +the piece on screen is repeatedly leaving the machine. ## Inference boundary The terminal interface is always Aesthetic Code. Engines are internal bridges, not alternate client interfaces or command shortcuts. -The current bridge uses Codex app-server and remote inference. The interface -labels this before a prompt is sent. Provider terms govern that traffic. +Two bridges exist, both remote: Claude Code in headless stream-json mode +(the default, on `claude-fable-5-1`) and Codex app-server. `--backend` and +`/backend` choose between them and `--model` and `/model` name the model. The +interface labels remote inference before a prompt is sent. Provider terms +govern that traffic, and each bridge signs in with its own vendor's existing +credentials on this machine; Aesthetic Code stores no key of its own. + +Neither bridge inherits the user's own agent configuration. Codex is started +with `on-request` approvals and a `workspace-write` sandbox regardless of what +`~/.codex/config.toml` says; Claude is started with `--setting-sources ""` and +`--strict-mcp-config`, so the user's allow-lists, hooks and MCP servers are not +in the session. On both, an approval is answered in this terminal and nowhere +else, and an `a` — allow for the session — is held in memory for the life of +the session rather than written to a settings file. + +### The sandbox gap on the Claude bridge + +The two bridges are not equivalent on containment, and the difference is worth +stating rather than papering over. + +Codex runs commands under an operating-system sandbox: writes are confined to +the workspace and `networkAccess` is false, so an approved command still cannot +reach the network without a second, explicit escalation. + +Claude Code has no equivalent sandbox. On that bridge Aesthetic Code confines +the file tools to the workspace, removes WebFetch and WebSearch, and routes +every prompt to this terminal — and Claude does prompt before a command that +touches the network — but the prompt is the whole boundary. A shell command the +user approves runs with the user's own privileges and can reach the network. +Read-only commands are auto-approved by Claude's own classifier, as reads +inside the sandbox are on the Codex bridge. + +Aesthetic Code therefore does not claim that agent tools are network-isolated +on the Claude bridge. Where that matters, `--backend codex` is the bridge with +a kernel behind its approvals. A future local bridge must route inference only to a loopback or explicitly configured private endpoint, reject known cloud model names, and disable diff --git a/src/app-server.mjs b/src/app-server.mjs index 822423d0e4..6451144fc4 100644 --- a/src/app-server.mjs +++ b/src/app-server.mjs @@ -12,6 +12,9 @@ export class AppServer extends EventEmitter { args = ["app-server", "--stdio"], environment = {}, developerInstructions = "", + // Empty means "whatever ~/.codex/config.toml says", which is how this + // bridge has always chosen a model. A name here overrides it per thread. + model = "", }) { super(); this.cwd = cwd; @@ -20,6 +23,7 @@ export class AppServer extends EventEmitter { this.environment = environment; this.resumeThreadId = resumeThreadId; this.developerInstructions = developerInstructions; + this.model = model; this.child = null; this.nextId = 1; this.pending = new Map(); @@ -80,6 +84,7 @@ export class AppServer extends EventEmitter { sandbox: "workspace-write", ephemeral: false, sessionStartSource: this.threadId ? "clear" : "startup", + ...(this.model ? { model: this.model } : {}), ...(this.developerInstructions ? { developerInstructions: this.developerInstructions } : {}), }); this.threadId = result.thread.id; @@ -94,6 +99,7 @@ export class AppServer extends EventEmitter { approvalPolicy: "on-request", approvalsReviewer: "user", sandbox: "workspace-write", + ...(this.model ? { model: this.model } : {}), ...(this.developerInstructions ? { developerInstructions: this.developerInstructions } : {}), }); this.threadId = result.thread.id; diff --git a/src/backends.mjs b/src/backends.mjs new file mode 100644 index 0000000000..97bcc259a5 --- /dev/null +++ b/src/backends.mjs @@ -0,0 +1,62 @@ +// backends.mjs — the engine bridges Aesthetic Code can drive. +// +// A bridge is a subprocess speaking a line protocol over stdio. The interface +// holds the same conversation over either of them — a thread, turns inside it, +// streamed text, tool activity, and approvals answered in this terminal — so a +// bridge is chosen at launch with `--backend`, or swapped mid-session with +// `/backend`, and nothing else in the interface changes. +// +// Claude is the default. Codex was the first and still works exactly as it +// did; the two differ mainly in where the model name comes from, which is why +// each one carries its own answer for `/model`. +import { AppServer } from "./app-server.mjs"; +import { ClaudeServer, DEFAULT_CLAUDE_MODEL } from "./claude-server.mjs"; + +export const BACKENDS = { + claude: { + id: "claude", + label: "claude", + // The executable that has to be on PATH for this bridge to start. + command: "claude", + defaultModel: DEFAULT_CLAUDE_MODEL, + // Where a model comes from when the interface does not name one. + modelSource: "the --model flag", + Engine: ClaudeServer, + }, + codex: { + id: "codex", + label: "codex", + command: "codex", + // Empty: Codex reads its own configuration unless a name is given. + defaultModel: "", + modelSource: "~/.codex/config.toml", + Engine: AppServer, + }, +}; + +// The names people reach for for the same two bridges. +export const BACKEND_ALIASES = { + anthropic: "claude", + fable: "claude", + openai: "codex", + gpt: "codex", +}; + +export const DEFAULT_BACKEND = "claude"; + +export function backendIds() { + return Object.keys(BACKENDS); +} + +export function backendMenu() { + return backendIds().join(", "); +} + +export function backendFor(id) { + const wanted = String(id || "").trim().toLowerCase(); + const backend = BACKENDS[BACKEND_ALIASES[wanted] || wanted]; + if (!backend) { + throw new Error(`unknown backend "${id}" — try ${backendMenu()}`); + } + return backend; +} diff --git a/src/claude-server.mjs b/src/claude-server.mjs new file mode 100644 index 0000000000..4e25475656 --- /dev/null +++ b/src/claude-server.mjs @@ -0,0 +1,509 @@ +// claude-server.mjs — the Claude engine bridge. +// +// The same shape as app-server.mjs: a subprocess on stdio, one thread, turns +// inside it, streamed text, and every approval coming back to this terminal. +// Where Codex speaks its own JSON-RPC, Claude Code speaks the headless +// stream-json protocol — `claude --print --input-format stream-json +// --output-format stream-json` — so this file is a translator. It reads +// Claude's messages and emits the notifications the interface already listens +// for, and it answers Claude's permission requests with the interface's own +// y/a/n. +// +// Two flags carry the approval contract, and both are load-bearing: +// +// --permission-prompt-tool stdio tells Claude that this process answers +// permission prompts. Without it the CLI has nobody to ask, every prompt is +// auto-denied, and the model simply narrates that it lacks permission — the +// session looks broken rather than gated. +// +// --setting-sources "" keeps the user's own settings out of a piece-writing +// session: their allow-lists, hooks and MCP servers. Codex's bridge does the +// same thing by overriding a permissive ~/.codex/config.toml with +// `on-request` and `workspace-write` at thread/start. The person watching +// this terminal should be the only thing that can approve a command in it. +// +// What this bridge cannot carry is the other half of Codex's posture: an +// operating-system sandbox. Codex runs commands with `workspace-write` and +// `networkAccess: false`; Claude Code has no equivalent, so a command reaches +// the network once it is approved. Reads and writes are confined to the +// workspace, WebFetch and WebSearch are removed, and network commands do +// prompt — but the prompt, not the kernel, is the boundary. See +// docs/local-contract.md. +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { EventEmitter } from "node:events"; +import { createInterface } from "node:readline"; + +const VERSION = "0.4.0"; + +// Anthropic's most capable model, and what a session opens on. +export const DEFAULT_CLAUDE_MODEL = "claude-fable-5-1"; + +// Tools whose work is a file change, and the input field naming the file. The +// interface watches these to follow the piece the agent is writing. +const FILE_TOOLS = new Map([ + ["Write", "file_path"], + ["Edit", "file_path"], + ["MultiEdit", "file_path"], + ["NotebookEdit", "notebook_path"], +]); + +const COMMAND_TOOLS = new Set(["Bash", "BashOutput", "KillShell"]); + +// Tools this interface has no room for: two that leave the machine on their +// own, and one that hides a whole second conversation behind a single line. +const WITHHELD_TOOLS = ["WebFetch", "WebSearch", "Task"]; + +// A short second line for a tool that is neither a command nor a file change. +function toolDetail(input = {}) { + for (const key of ["pattern", "query", "path", "file_path", "url", "description"]) { + const value = input[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +export class ClaudeServer extends EventEmitter { + constructor({ + cwd, + resumeThreadId = "", + command = "claude", + args = [], + environment = {}, + developerInstructions = "", + model = DEFAULT_CLAUDE_MODEL, + }) { + super(); + this.cwd = cwd; + this.command = command; + this.args = args; + this.environment = environment; + this.resumeThreadId = resumeThreadId; + this.developerInstructions = developerInstructions; + this.model = model || DEFAULT_CLAUDE_MODEL; + this.child = null; + this.threadId = null; + this.turnId = null; + this.closed = false; + this.restarting = false; + this.turns = 0; + this.controls = 0; + this.handshakeId = ""; + this.announced = false; + this.ready = null; + // Tool calls in flight, so a tool result can complete the line the tool + // call opened. Approvals in flight, so a y/a/n can find its request. + this.items = new Map(); + this.approvals = new Map(); + // Text streaming state for the message being written right now. + this.messageId = ""; + this.textItems = new Map(); + this.streamedText = false; + } + + async connect() { + return this.#launch(this.resumeThreadId); + } + + // Codex starts a fresh thread inside one long-lived process; a headless + // Claude process is bound to one session, so a new thread is a new process. + // The conversation lives in Claude's own session file either way. + async newThread() { + return this.#launch(""); + } + + async resumeThread(threadId) { + return this.#launch(threadId); + } + + async startTurn(text) { + if (!this.child) throw new Error("thread is not ready"); + this.turnId = `turn-${++this.turns}`; + const turn = { id: this.turnId, status: "inProgress", items: [] }; + this.#send({ + type: "user", + message: { role: "user", content: [{ type: "text", text }] }, + parent_tool_use_id: null, + session_id: this.threadId, + }); + this.emit("notification", { method: "turn/started", params: { turn } }); + return { turn }; + } + + async interrupt() { + if (!this.child || !this.turnId) return; + this.#send({ + type: "control_request", + request_id: `ac-${++this.controls}`, + request: { subtype: "interrupt" }, + }); + } + + // The interface answers an approval with the same four decisions it gives + // Codex. `acceptForSession` takes the rules Claude itself suggests for the + // call and pins them to the session, so nothing is written to a settings + // file on disk. + respond(id, result) { + const approval = this.approvals.get(id); + if (!approval) return; + this.approvals.delete(id); + const decision = result?.decision || "decline"; + let response; + if (decision === "accept" || decision === "acceptForSession") { + response = { behavior: "allow", updatedInput: approval.input }; + if (decision === "acceptForSession") { + response.updatedPermissions = this.#sessionRules(approval); + } + } else { + response = { + behavior: "deny", + message: + decision === "cancel" + ? "Cancelled in Aesthetic Code." + : "Denied in Aesthetic Code.", + }; + } + this.#send({ + type: "control_response", + response: { subtype: "success", request_id: id, response }, + }); + if (decision === "cancel") this.interrupt().catch(() => {}); + } + + reject(id, _code, message) { + if (this.approvals.delete(id)) { + this.#send({ + type: "control_response", + response: { + subtype: "success", + request_id: id, + response: { behavior: "deny", message: message || "Denied." }, + }, + }); + return; + } + this.#send({ + type: "control_response", + response: { subtype: "error", request_id: id, error: message || "unsupported" }, + }); + } + + close() { + if (this.closed) return; + this.closed = true; + this.child?.kill("SIGTERM"); + } + + #sessionRules({ tool, suggestions }) { + const rules = (suggestions || []) + .filter((suggestion) => suggestion?.type === "addRules" || suggestion?.type === "setMode") + .map((suggestion) => ({ ...suggestion, destination: "session" })); + if (rules.length) return rules; + return [ + { + type: "addRules", + rules: [{ toolName: tool }], + behavior: "allow", + destination: "session", + }, + ]; + } + + #launchArguments(resume) { + const args = [ + "--print", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + // Text arrives token by token rather than a paragraph at a time, which + // is what makes the transcript read like Codex's. + "--include-partial-messages", + "--verbose", + "--model", + this.model, + "--permission-mode", + "manual", + "--permission-prompts", + "host", + "--permission-prompt-tool", + "stdio", + "--setting-sources", + "", + "--strict-mcp-config", + "--disallowed-tools", + ...WITHHELD_TOOLS, + "--add-dir", + this.cwd, + ]; + if (this.developerInstructions) { + args.push("--append-system-prompt", this.developerInstructions); + } + // The thread id is minted here rather than read back, so the interface + // knows what to call this conversation before the first turn. + if (resume) args.push("--resume", resume); + else args.push("--session-id", this.threadId); + // `args` is a prefix — a script for the command to run before its own + // flags — which is how a test points this at a stand-in for the CLI. + return [...this.args, ...args]; + } + + #launch(resume) { + if (this.child) { + this.restarting = true; + this.child.kill("SIGTERM"); + this.child = null; + } + this.items.clear(); + this.approvals.clear(); + this.turnId = null; + this.threadId = resume || randomUUID(); + this.announced = false; + + const child = spawn(this.command, this.#launchArguments(resume), { + cwd: this.cwd, + env: { + ...process.env, + ...this.environment, + AESTHETIC_CODE: "1", + AESTHETIC_CODE_VERSION: VERSION, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + this.child = child; + this.restarting = false; + + let settle; + let fail; + const ready = { + promise: new Promise((resolve, reject) => { + settle = resolve; + fail = reject; + }), + }; + ready.resolve = settle; + ready.reject = fail; + this.ready = ready; + + child.once("error", (error) => { + ready.reject(error); + this.#fatal(error); + }); + child.once("exit", (code, signal) => { + if (child !== this.child) return; // Replaced by /new; not a failure. + const suffix = signal ? ` (${signal})` : code === null ? "" : ` (${code})`; + const error = new Error(`engine bridge closed${suffix}`); + ready.reject(error); + this.#fatal(error); + this.emit("exit", { code, signal }); + }); + + createInterface({ input: child.stdout }).on("line", (line) => { + if (!line.trim()) return; + try { + this.#receive(JSON.parse(line)); + } catch (error) { + this.emit("protocolError", new Error(`invalid engine message: ${error.message}`)); + } + }); + createInterface({ input: child.stderr }).on("line", (line) => { + if (line.trim()) this.emit("log", line.trim()); + }); + + // The handshake that makes this process a host: it registers us for the + // control channel the permission prompts come back on, and its answer is + // the bridge saying it is ready. + this.handshakeId = `ac-${++this.controls}`; + this.#send({ + type: "control_request", + request_id: this.handshakeId, + request: { subtype: "initialize", hooks: {} }, + }); + + return ready.promise; + } + + #send(message) { + if (!this.child?.stdin.writable) throw new Error("engine bridge is not writable"); + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } + + #fatal(error) { + if (this.closed) return; + this.emit("fatal", error); + } + + #receive(message) { + switch (message.type) { + case "system": + return this.#system(message); + case "stream_event": + return this.#streamEvent(message.event || {}); + case "assistant": + return this.#assistant(message); + case "user": + return this.#toolResults(message); + case "control_request": + return this.#controlRequest(message); + case "control_response": + return this.#controlResponse(message); + case "result": + return this.#result(message); + default: + } + } + + // The handshake is what says the bridge is up: `system/init` does not + // arrive until the first turn begins, so waiting for it would deadlock a + // session that has not been typed into yet. + #controlResponse(message) { + const response = message.response || {}; + if (response.request_id !== this.handshakeId) return; + if (response.subtype === "error") { + this.ready?.reject(new Error(response.error || "engine bridge refused the handshake")); + return; + } + this.ready?.resolve({ + thread: { id: this.threadId, turns: [] }, + model: this.model, + cwd: this.cwd, + }); + } + + // `system/init` opens each turn, and carries what the CLI resolved the + // requested model and session to. Say so once if either differs. + #system(message) { + if (message.subtype !== "init" || this.announced) return; + this.announced = true; + if (message.session_id) this.threadId = message.session_id; + if (message.model && message.model !== this.model) { + this.model = message.model; + this.emit("notification", { method: "warning", params: { message: `Running ${message.model}` } }); + } + } + + #streamEvent(event) { + if (event.type === "message_start") { + this.messageId = event.message?.id || `message-${Date.now()}`; + this.textItems.clear(); + this.streamedText = false; + return; + } + if (event.type === "content_block_start" && event.content_block?.type === "text") { + this.textItems.set(event.index, `${this.messageId}:${event.index}`); + return; + } + if (event.type === "content_block_delta" && event.delta?.type === "text_delta") { + const itemId = this.textItems.get(event.index); + if (!itemId) return; + this.streamedText = true; + this.emit("notification", { + method: "item/agentMessage/delta", + params: { itemId, delta: event.delta.text }, + }); + } + } + + #assistant(message) { + const content = message.message?.content || []; + // A rate limit or a refused model comes back as a synthetic assistant + // message with no stream behind it. It reads as an error, not as an answer. + if (message.is_api_error_message) return; + let index = 0; + for (const block of content) { + if (block.type === "tool_use") { + const item = this.#toolItem(block); + this.items.set(block.id, item); + this.emit("notification", { method: "item/started", params: { item } }); + } else if (block.type === "text" && block.text && !this.streamedText) { + // Belt and braces: text that never streamed still reaches the screen. + this.emit("notification", { + method: "item/completed", + params: { + item: { id: `${message.message?.id || this.messageId}:t${index}`, type: "agentMessage", text: block.text }, + }, + }); + } + index += 1; + } + } + + #toolItem(block) { + const field = FILE_TOOLS.get(block.name); + if (field) { + const path = block.input?.[field]; + return { id: block.id, type: "fileChange", changes: path ? [{ path }] : [] }; + } + if (COMMAND_TOOLS.has(block.name)) { + return { id: block.id, type: "commandExecution", command: block.input?.command || block.name }; + } + const detail = toolDetail(block.input); + return { id: block.id, type: "dynamicToolCall", tool: detail ? `${block.name} · ${detail}` : block.name }; + } + + #toolResults(message) { + for (const block of message.message?.content || []) { + if (block.type !== "tool_result") continue; + const item = this.items.get(block.tool_use_id); + if (!item) continue; + this.items.delete(block.tool_use_id); + const failed = Boolean(block.is_error); + const completed = + item.type === "commandExecution" + ? { ...item, exitCode: failed ? 1 : 0 } + : { ...item, status: failed ? "failed" : "completed" }; + this.emit("notification", { method: "item/completed", params: { item: completed } }); + } + } + + #controlRequest(message) { + const request = message.request || {}; + if (request.subtype !== "can_use_tool") { + this.#send({ + type: "control_response", + response: { + subtype: "error", + request_id: message.request_id, + error: `Aesthetic Code does not support ${request.subtype} yet`, + }, + }); + return; + } + const id = message.request_id; + this.approvals.set(id, { + tool: request.tool_name, + input: request.input, + suggestions: request.permission_suggestions || [], + }); + const field = FILE_TOOLS.get(request.tool_name); + if (field) { + this.emit("request", { + id, + method: "item/fileChange/requestApproval", + params: { reason: request.input?.[field] || request.description || request.tool_name }, + }); + return; + } + const detail = COMMAND_TOOLS.has(request.tool_name) + ? request.input?.command + : `${request.tool_name}${request.description ? ` · ${request.description}` : ""}`; + this.emit("request", { + id, + method: "item/commandExecution/requestApproval", + params: { command: detail || request.tool_name }, + }); + } + + #result(message) { + const id = this.turnId || `turn-${this.turns}`; + this.turnId = null; + this.textItems.clear(); + const aborted = String(message.terminal_reason || "").startsWith("aborted"); + const status = aborted ? "interrupted" : message.is_error ? "failed" : "completed"; + const turn = { id, status, items: [] }; + if (status === "failed") { + turn.error = { message: String(message.result || message.terminal_reason || "turn failed") }; + } + this.emit("notification", { method: "turn/completed", params: { turn } }); + } +} diff --git a/src/live.mjs b/src/live.mjs index 945675386a..7eef73ea81 100644 --- a/src/live.mjs +++ b/src/live.mjs @@ -9,8 +9,15 @@ // This is the path the VS Code extension has always used: POST /run publishes // { piece, source, codeChannel } to Redis, the session server relays it to the // channel's subscribers, and the client swaps the running piece. A viewer joins -// the channel by opening `prompt~channel~~!autorun`, which runs the +// the channel by opening `prompt~channel%20~!autorun`, which runs the // prompt's own `channel` command on arrival. +// +// The `%20` is load-bearing. parse.mjs special-cases anything beginning +// `prompt~` and hands the whole remainder to the prompt as ONE parameter, so +// the prompt sees the string `channel~` and splits its own arguments +// on spaces — a tilde-separated argument arrives glued to the command name and +// the channel is silently dropped, leaving the phone on an empty prompt. An +// encoded space is what actually reaches `halt` as two tokens. import { EventEmitter } from "node:events"; import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; import { basename, dirname, extname, join, resolve } from "node:path"; @@ -58,6 +65,7 @@ export class LivePiece extends EventEmitter { this.blank = ""; this.watcher = null; this.debounce = null; + this.sending = false; this.pushes = 0; } @@ -66,9 +74,10 @@ export class LivePiece extends EventEmitter { } // The URL a phone scans: it joins the code channel and then sits waiting for - // source, which arrives as soon as anything is pushed. + // source, which arrives as soon as anything is pushed. See the note at the + // top of the file for why the argument is separated by an encoded space. get scanUrl() { - return `${this.scanHost}/prompt~channel~${this.channel}~!autorun`; + return `${this.scanHost}/prompt~channel%20${this.channel}~!autorun`; } // Where the piece answers once it has been published under a handle. @@ -159,12 +168,17 @@ export class LivePiece extends EventEmitter { async push() { const source = this.source(); if (!source.trim()) return false; - const response = await this.fetch(`${this.site}/run`, { - method: "POST", - headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT }, - body: JSON.stringify({ piece: this.slug, source, codeChannel: this.channel }), - }); - if (!response.ok) throw new Error(`live push failed (HTTP ${response.status})`); + this.sending = true; + try { + const response = await this.fetch(`${this.site}/run`, { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT }, + body: JSON.stringify({ piece: this.slug, source, codeChannel: this.channel }), + }); + if (!response.ok) throw new Error(`live push failed (HTTP ${response.status})`); + } finally { + this.sending = false; + } this.pushes += 1; this.emit("push", this.pushes); return true; @@ -173,6 +187,13 @@ export class LivePiece extends EventEmitter { // Watch the whole directory rather than the file: editors and patch tools // replace files instead of writing through them, which leaves a file watch // pointed at a discarded inode. + // + // One push per save is enough. The session server keeps the last message a + // channel received and replays it to whoever joins later, so the piece a + // session opens with is still waiting when a phone finally scans the code. + // This carried a 4s re-announce heartbeat until that retention landed + // (aesthetic-computer d8d3c80053); a beat per session per 4s to cover a + // one-line server bug was rent, not architecture. watch(onError = () => {}) { this.unwatch(); try { diff --git a/src/runtimes.mjs b/src/runtimes.mjs index 7be30023d3..76f28a175f 100644 --- a/src/runtimes.mjs +++ b/src/runtimes.mjs @@ -39,23 +39,40 @@ export { paint }; }, lua: { id: "lua", - label: "lua", + label: "processing", extension: ".lua", mime: "text/x-lua; charset=utf-8", - // Live pushes run, but aesthetic.computer/@handle/ only resolves - // .mjs and .lisp today, so a published .lua has no front door yet. + // Lua is the language of L5, Aesthetic Computer's Processing surface, so a + // piece here is written in Processing's vocabulary — setup and draw, not + // paint — against `lib/l5.mjs`. + // + // The shape of the blank is load-bearing. A push over the code channel + // carries only { piece, source, codeChannel }: no extension, no language. + // The client recognises Lua in that message by reading the source — it must + // begin with a `--` comment AND declare `function setup(` or `function + // draw(` — and anything else is compiled as JavaScript and fails. Both are + // natural in real L5, and this blank keeps them. routable: false, blank: (name) => `-- ${name}, ${stamp()} +-- A blank Aesthetic Computer piece, in Processing (L5). -function paint() - wipe(70, 50, 100) - ink(255, 100, 255) - write("${name}", 6, 6) +function setup() + noStroke() +end + +function draw() + background(70, 50, 100) + fill(255, 100, 255) + text("${name}", 6, 16) end `, }, }; +// Some runtimes answer to more than one name. Lua is the language; Processing +// is what it is for. +export const RUNTIME_ALIASES = { processing: "lua", l5: "lua", js: "mjs", kidlisp: "lisp" }; + export const DEFAULT_RUNTIME = "mjs"; function stamp(date = new Date()) { @@ -71,8 +88,18 @@ export function runtimeIds() { return Object.keys(RUNTIMES); } +// How the runtimes read in the interface: the id to type, named by what it is +// where the two differ. `lua` is the one worth spelling out — nobody comes +// looking for Lua, they come looking for Processing. +export function runtimeMenu() { + return runtimeIds() + .map((id) => (RUNTIMES[id].label === id ? id : `${id} (${RUNTIMES[id].label})`)) + .join(", "); +} + export function runtimeFor(id) { - const runtime = RUNTIMES[String(id || "").replace(/^\./, "").toLowerCase()]; + const wanted = String(id || "").replace(/^\./, "").toLowerCase(); + const runtime = RUNTIMES[RUNTIME_ALIASES[wanted] || wanted]; if (!runtime) { throw new Error(`unknown runtime "${id}" — try ${runtimeIds().join(", ")}`); } diff --git a/src/tui.mjs b/src/tui.mjs index 6a75238932..b49c0dd984 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -3,12 +3,12 @@ import path from "node:path"; import process from "node:process"; import { ACSession } from "./ac-session.mjs"; -import { AppServer } from "./app-server.mjs"; +import { backendFor, backendMenu, DEFAULT_BACKEND } from "./backends.mjs"; import { LivePiece } from "./live.mjs"; import { publishPiece } from "./publish.mjs"; import { qrBlock } from "./qr.mjs"; import { cleanText, renderFrame } from "./render.mjs"; -import { DEFAULT_RUNTIME, runtimeIds } from "./runtimes.mjs"; +import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; const arguments_ = process.argv.slice(2); @@ -19,6 +19,10 @@ const option = (name) => { const cwd = path.resolve(option("--cwd") || process.cwd()); const resumeThreadId = option("--resume"); const initialPrompt = option("--prompt"); +// Which engine bridge drives the conversation, and on which model. The bridge +// can be swapped mid-session with /backend, so neither is a constant. +let backend = backendFor(option("--backend") || process.env.AESTHETIC_CODE_BACKEND || DEFAULT_BACKEND); +let model = option("--model") || backend.defaultModel; const session = new ACSession(); // Every session opens on a new blank piece with a random name. It is a real @@ -36,6 +40,8 @@ const state = { approval: null, account: session.label(), piece: "", + // What the bridge said it is running, once it has said so. + model: "", qr: null, showQr: true, entries: [ @@ -52,10 +58,20 @@ function developerInstructions() { const account = session.handle ? `The user is signed in to Aesthetic Computer as @${session.handle}.` : "The user is not signed in to Aesthetic Computer; /login signs them in."; + // A live push carries no file extension, so a Processing piece is recognised + // by its own opening lines. Rewriting them silently takes the phone dark. + const dialect = + live.runtime.id === "lua" + ? [ + "This is a Processing (L5) piece: write Processing, not Aesthetic Computer JavaScript — `setup` and `draw`, `background`, `fill`, `circle`, `text`, `width`, `height`, `frameCount`, `mouseX`, `mouseY`, `mouseIsPressed`. There is no `paint`, `wipe`, or `ink`.", + "Keep the file's first line a `--` comment and keep a top-level `function setup(` or `function draw(`. The live channel sends no file extension, so those two things are the only way the piece is recognised as Lua rather than compiled as JavaScript — drop either and the phone goes blank.", + ] + : []; return [ "You are running inside Aesthetic Code, a terminal interface for Aesthetic Computer (AC) work.", account, `This session's piece is ${live.file} (${live.runtime.label}). It already exists as a blank piece. Edit that file unless the user asks for something else.`, + ...dialect, "Every save of that file is pushed live to a phone that scanned the interface's QR code, so small frequent edits are better than one big rewrite.", "Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.", "A piece is live only after the user runs the Aesthetic Code command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", @@ -67,16 +83,37 @@ function developerInstructions() { const slabSession = new SlabSession({ cwd }); slabSession.start(); slabSession.identity(session.handle); -const engine = new AppServer({ - cwd, - resumeThreadId, - developerInstructions: developerInstructions(), - environment: { - SLAB_PROMPT_SESSION_ID: slabSession.sessionId, - SLAB_TERMINAL_TTY: slabSession.tty, - SLAB_AGENT_TYPE: "aesthetic-code", - }, -}); + +// One engine at a time, wired to the same handlers however it was built. +function openEngine({ resume = "" } = {}) { + const opened = new backend.Engine({ + cwd, + resumeThreadId: resume, + model, + developerInstructions: developerInstructions(), + environment: { + SLAB_PROMPT_SESSION_ID: slabSession.sessionId, + SLAB_TERMINAL_TTY: slabSession.tty, + SLAB_AGENT_TYPE: "aesthetic-code", + }, + }); + opened.on("notification", handleNotification); + opened.on("request", handleRequest); + opened.on("protocolError", (error) => { + addEntry("error", errorText(error)); + redraw(); + }); + opened.on("fatal", (error) => { + if (closing || opened !== engine) return; + state.status = "offline"; + addEntry("error", errorText(error)); + slabSession.awaitingInput("aesthetic code engine bridge is offline"); + redraw(); + }); + return opened; +} + +let engine = openEngine({ resume: resumeThreadId }); let drawing = false; let closing = false; let streamedMessageId = null; @@ -373,6 +410,71 @@ async function commandPublish(argumentText) { redraw(); } +// ── engine commands ───────────────────────────────────────────────────── + +function engineLabel() { + return `${backend.label} · ${state.model || model || backend.modelSource}`; +} + +// Open a thread on the current bridge, replacing whatever is running. This is +// what /new, /backend and /model all come down to: the conversation restarts, +// the piece and the QR code do not. +async function restartEngine(note) { + state.status = "starting"; + redraw(); + const previous = engine; + engine = openEngine(); + previous.close(); + try { + const connection = await engine.connect(); + slabSession.connected(engine.threadId); + state.model = connection?.model || model; + state.entries = [{ kind: "notice", text: `${note} · ${engineLabel()}`, id: `thread-${Date.now()}` }]; + state.status = "ready"; + } catch (error) { + addEntry("error", errorText(error)); + state.status = "failed"; + } + redraw(); +} + +async function commandBackend(rest) { + if (!rest) { + addEntry("notice", `${engineLabel()} · backends: ${backendMenu()}`); + return redraw(); + } + if (state.busy) { + addEntry("error", "Interrupt the current turn before switching engines."); + return redraw(); + } + const [wanted, wantedModel = ""] = rest.split(/\s+/).filter(Boolean); + let next; + try { + next = backendFor(wanted); + } catch (error) { + addEntry("error", errorText(error)); + return redraw(); + } + backend = next; + model = wantedModel || next.defaultModel; + state.model = ""; + return restartEngine("Engine"); +} + +async function commandModel(rest) { + if (!rest) { + addEntry("notice", engineLabel()); + return redraw(); + } + if (state.busy) { + addEntry("error", "Interrupt the current turn before switching models."); + return redraw(); + } + model = rest.split(/\s+/)[0]; + state.model = ""; + return restartEngine("Model"); +} + async function submitInput() { const text = state.input.trim(); state.input = ""; @@ -391,7 +493,7 @@ async function submitInput() { if (command === "/help") { addEntry( "notice", - "/login · /logout · /whoami · /publish [file] · /piece [name] · /runtime [id] · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", + "/login · /logout · /whoami · /publish [file] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -403,6 +505,8 @@ async function submitInput() { return redraw(); } if (command === "/publish") return commandPublish(rest); + if (command === "/backend" || command === "/engine") return commandBackend(rest); + if (command === "/model") return commandModel(rest); if (command === "/piece") { if (rest) { try { @@ -420,7 +524,7 @@ async function submitInput() { } if (command === "/runtime") { if (!rest) { - addEntry("notice", `${live.runtime.label} · runtimes: ${runtimeIds().join(", ")}`); + addEntry("notice", `${live.runtime.label} · runtimes: ${runtimeMenu()}`); return redraw(); } try { @@ -463,7 +567,9 @@ async function submitInput() { engine.developerInstructions = developerInstructions(); await engine.newThread(); slabSession.connected(engine.threadId); - state.entries = [{ kind: "notice", text: "New thread", id: `thread-${Date.now()}` }]; + state.entries = [ + { kind: "notice", text: `New thread · ${engineLabel()}`, id: `thread-${Date.now()}` }, + ]; state.status = "ready"; } catch (error) { addEntry("error", errorText(error)); @@ -588,21 +694,6 @@ session.watch().on("change", () => { redraw(); }); -engine.on("notification", handleNotification); -engine.on("request", handleRequest); -engine.on("protocolError", (error) => { - addEntry("error", errorText(error)); - redraw(); -}); -engine.on("fatal", (error) => { - if (!closing) { - state.status = "offline"; - addEntry("error", errorText(error)); - slabSession.awaitingInput("aesthetic code engine bridge is offline"); - redraw(); - } -}); - // Mint this session's blank piece and the QR code that opens it on a phone. live.create(); live.watch(liveError); @@ -614,8 +705,12 @@ try { const connection = await engine.connect(); slabSession.connected(connection.thread.id); state.status = "ready"; - if (resumeThreadId && !restoreThread(connection.thread)) addEntry("notice", "Resumed thread"); - else if (connection?.model) addEntry("notice", `Ready · ${connection.model}`); + state.model = connection?.model || model; + if (resumeThreadId && !restoreThread(connection.thread)) { + addEntry("notice", `Resumed thread · ${engineLabel()}`); + } else { + addEntry("notice", `Ready · ${engineLabel()}`); + } if (!session.signedIn) { addEntry("notice", "Not signed in to Aesthetic Computer · /login to publish under your @handle"); } diff --git a/test/claude-server.test.mjs b/test/claude-server.test.mjs new file mode 100644 index 0000000000..823557c0ce --- /dev/null +++ b/test/claude-server.test.mjs @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { ClaudeServer, DEFAULT_CLAUDE_MODEL } from "../src/claude-server.mjs"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const fake = path.join(directory, "fake-claude-cli.mjs"); + +function scratch() { + const root = mkdtempSync(path.join(tmpdir(), "aesthetic-claude-")); + return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +function bridge(t, options = {}) { + const engine = new ClaudeServer({ + cwd: directory, + command: process.execPath, + args: [fake], + ...options, + }); + t.after(() => engine.close()); + return engine; +} + +test("drives a turn, streams text, and routes the approval to the interface", async (t) => { + const { root, cleanup } = scratch(); + const decisionFile = path.join(root, "decision.json"); + const engine = bridge(t, { environment: { FAKE_CLAUDE_DECISION: decisionFile } }); + + const deltas = []; + const items = []; + const completed = new Promise((resolve) => { + engine.on("notification", ({ method, params }) => { + if (method === "item/agentMessage/delta") deltas.push(params.delta); + if (method === "item/started") items.push(params.item); + if (method === "item/completed") items.push(params.item); + if (method === "turn/completed") resolve(params.turn.status); + }); + }); + engine.on("request", (request) => engine.respond(request.id, { decision: "accept" })); + + const connection = await engine.connect(); + // The bridge is ready on the handshake, before the CLI has opened a turn, + // so the thread is named by the id the launch minted. + assert.match(connection.thread.id, /^[0-9a-f-]{36}$/); + assert.equal(connection.thread.id, engine.threadId); + assert.equal(connection.model, DEFAULT_CLAUDE_MODEL); + + const turn = await engine.startTurn("make a piece"); + assert.equal(turn.turn.status, "inProgress"); + assert.equal(await completed, "completed"); + assert.equal(deltas.join(""), "Writing the piece."); + + // The interface follows the piece through fileChange items. + const started = items.find((item) => item.type === "fileChange"); + assert.deepEqual(started.changes, [{ path: "/tmp/piece.mjs" }]); + assert.equal(items.at(-1).status, "completed"); + + assert.deepEqual(JSON.parse(readFileSync(decisionFile, "utf8")).behavior, "allow"); + cleanup(); +}); + +test("a declined approval fails the item rather than the turn", async (t) => { + const engine = bridge(t); + const items = []; + const completed = new Promise((resolve) => { + engine.on("notification", ({ method, params }) => { + if (method === "item/completed") items.push(params.item); + if (method === "turn/completed") resolve(params.turn.status); + }); + }); + engine.on("request", (request) => engine.respond(request.id, { decision: "decline" })); + + await engine.connect(); + await engine.startTurn("make a piece"); + assert.equal(await completed, "completed"); + assert.equal(items.at(-1).status, "failed"); +}); + +test("accept-for-session pins the suggested rules to the session, never to disk", async (t) => { + const { root, cleanup } = scratch(); + const decisionFile = path.join(root, "decision.json"); + const engine = bridge(t, { environment: { FAKE_CLAUDE_DECISION: decisionFile } }); + const completed = new Promise((resolve) => { + engine.on("notification", ({ method, params }) => { + if (method === "turn/completed") resolve(params.turn.status); + }); + }); + engine.on("request", (request) => engine.respond(request.id, { decision: "acceptForSession" })); + + await engine.connect(); + await engine.startTurn("make a piece"); + await completed; + + const decision = JSON.parse(readFileSync(decisionFile, "utf8")); + assert.equal(decision.behavior, "allow"); + assert.equal(decision.updatedPermissions.length, 1); + assert.equal(decision.updatedPermissions[0].destination, "session"); + cleanup(); +}); + +test("the launch carries the approval contract and the workspace", async (t) => { + const { root, cleanup } = scratch(); + const argvFile = path.join(root, "argv.json"); + const engine = bridge(t, { + developerInstructions: "piece rules", + environment: { FAKE_CLAUDE_ARGV: argvFile }, + }); + await engine.connect(); + const argv = JSON.parse(readFileSync(argvFile, "utf8")); + const flag = (name) => argv[argv.indexOf(name) + 1]; + + assert.equal(flag("--model"), DEFAULT_CLAUDE_MODEL); + // Without this the CLI has nobody to ask and denies every prompt itself. + assert.equal(flag("--permission-prompt-tool"), "stdio"); + assert.equal(flag("--permission-prompts"), "host"); + assert.equal(flag("--permission-mode"), "manual"); + // The user's own allow-lists, hooks and MCP servers stay out of a session. + assert.equal(flag("--setting-sources"), ""); + assert.ok(argv.includes("--strict-mcp-config")); + for (const tool of ["WebFetch", "WebSearch", "Task"]) assert.ok(argv.includes(tool)); + assert.equal(flag("--add-dir"), directory); + assert.equal(flag("--append-system-prompt"), "piece rules"); + assert.ok(argv.includes("--session-id")); + assert.ok(!argv.includes("--resume")); + cleanup(); +}); + +test("resuming names the thread instead of minting one", async (t) => { + const { root, cleanup } = scratch(); + const argvFile = path.join(root, "argv.json"); + const threadId = "00000000-0000-0000-0000-000000000001"; + const engine = bridge(t, { resumeThreadId: threadId, environment: { FAKE_CLAUDE_ARGV: argvFile } }); + await engine.connect(); + const argv = JSON.parse(readFileSync(argvFile, "utf8")); + assert.equal(argv[argv.indexOf("--resume") + 1], threadId); + assert.ok(!argv.includes("--session-id")); + assert.equal(engine.threadId, threadId); + cleanup(); +}); + +test("an interrupted turn reads as interrupted, not as a failure", async (t) => { + const engine = bridge(t); + const completed = new Promise((resolve) => { + engine.on("notification", ({ method, params }) => { + if (method === "turn/completed") resolve(params.turn); + }); + }); + await engine.connect(); + await engine.startTurn("count to a thousand"); + await engine.interrupt(); + const turn = await completed; + assert.equal(turn.status, "interrupted"); + assert.equal(turn.error, undefined); +}); diff --git a/test/cli.sh b/test/cli.sh index 41a168a1cb..c581240178 100755 --- a/test/cli.sh +++ b/test/cli.sh @@ -78,6 +78,26 @@ assert_contains "$output" 'runtime=lisp' output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" assert_contains "$output" 'runtime=mjs' +# Claude is the default engine bridge, on Fable; Codex stays selectable. +assert_contains "$output" 'backend=claude' +assert_contains "$output" 'model=claude-fable-5-1' + +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --backend codex "$WORK_DIR")" +assert_contains "$output" 'backend=codex' + +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --backend claude --model claude-opus-5 "$WORK_DIR")" +assert_contains "$output" 'model=claude-opus-5' + +if AESTHETIC_CODE_DRY_RUN=1 "$CLI" --backend gemini "$WORK_DIR" >/dev/null 2>&1; then + printf 'Expected an unknown backend to fail.\n' >&2 + exit 1 +fi + +output="$($CLI doctor)" +assert_contains "$output" 'engine bridge claude:' +assert_contains "$output" 'engine bridge codex:' +assert_contains "$output" 'default engine bridge: claude (claude-fable-5-1)' + if AESTHETIC_CODE_DRY_RUN=1 "$CLI" --runtime rust "$WORK_DIR" >/dev/null 2>&1; then printf 'Expected an unknown runtime to fail.\n' >&2 exit 1 diff --git a/test/fake-claude-cli.mjs b/test/fake-claude-cli.mjs new file mode 100644 index 0000000000..6a5ae0b0fc --- /dev/null +++ b/test/fake-claude-cli.mjs @@ -0,0 +1,97 @@ +// A stand-in for `claude` in headless stream-json mode: enough of the +// protocol to drive one turn that writes a file and asks to do it. +import { writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; + +const argv = process.argv.slice(2); +if (process.env.FAKE_CLAUDE_ARGV) writeFileSync(process.env.FAKE_CLAUDE_ARGV, JSON.stringify(argv)); + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +const file = "/tmp/piece.mjs"; +const toolUseId = "toolu_1"; +// The real CLI honours --session-id and keeps a resumed id, and reports it +// back on every turn's init. +const sessionId = argv[argv.indexOf("--session-id") + 1] || argv[argv.indexOf("--resume") + 1] || "session-1"; + +createInterface({ input: process.stdin }).on("line", (line) => { + const message = JSON.parse(line); + + if (message.type === "control_request" && message.request?.subtype === "initialize") { + send({ + type: "control_response", + response: { subtype: "success", request_id: message.request_id, response: { commands: [] } }, + }); + send({ + type: "system", + subtype: "init", + session_id: sessionId, + model: "claude-fable-5-1", + cwd: process.cwd(), + }); + return; + } + + if (message.type === "control_request" && message.request?.subtype === "interrupt") { + send({ + type: "control_response", + response: { subtype: "success", request_id: message.request_id, response: {} }, + }); + send({ type: "result", subtype: "error_during_execution", is_error: true, terminal_reason: "aborted_streaming" }); + return; + } + + if (message.type === "user") { + send({ type: "stream_event", event: { type: "message_start", message: { id: "msg_1" } } }); + send({ type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } }); + for (const delta of ["Writing ", "the piece."]) { + send({ type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: delta } } }); + } + send({ + type: "assistant", + message: { + id: "msg_1", + content: [{ type: "tool_use", id: toolUseId, name: "Write", input: { file_path: file, content: "// hi\n" } }], + }, + }); + send({ + type: "control_request", + request_id: "perm-1", + request: { + subtype: "can_use_tool", + tool_name: "Write", + input: { file_path: file, content: "// hi\n" }, + description: "piece.mjs", + permission_suggestions: [ + { type: "addRules", rules: [{ toolName: "Write" }], behavior: "allow", destination: "localSettings" }, + ], + tool_use_id: toolUseId, + }, + }); + return; + } + + if (message.type === "control_response" && message.response?.request_id === "perm-1") { + if (process.env.FAKE_CLAUDE_DECISION) { + writeFileSync(process.env.FAKE_CLAUDE_DECISION, JSON.stringify(message.response.response)); + } + const allowed = message.response.response?.behavior === "allow"; + send({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: allowed ? `File created at ${file}` : "Denied.", + is_error: !allowed, + }, + ], + }, + }); + send({ type: "result", subtype: "success", is_error: false, terminal_reason: "completed", result: "done" }); + } +}); diff --git a/test/live.test.mjs b/test/live.test.mjs index 445c208a3c..2a3edc9187 100644 --- a/test/live.test.mjs +++ b/test/live.test.mjs @@ -7,7 +7,7 @@ import test from "node:test"; import { LivePiece, pieceDirectory } from "../src/live.mjs"; import { randomChannel, randomSlug } from "../src/names.mjs"; import { qrBlock } from "../src/qr.mjs"; -import { runtimeFor, runtimeForExtension, runtimeIds } from "../src/runtimes.mjs"; +import { runtimeFor, runtimeForExtension, runtimeIds, runtimeMenu } from "../src/runtimes.mjs"; async function workspace(context) { const root = await mkdtemp(join(tmpdir(), "aesthetic-code-live-")); @@ -23,9 +23,27 @@ test("names are pronounceable and channels stay short enough to scan", () => { assert.match(randomChannel(), /^[A-Za-z0-9_-]{8}$/); } // A version-3 QR is the largest that fits a terminal corner: 33 columns. - const block = qrBlock(`aesthetic.computer/prompt~channel~${randomChannel()}~!autorun`); - assert.equal(block.width, 33); - assert.equal(block.height, 17); + // Measure the real scan URL rather than a copy of it, so lengthening the URL + // fails here instead of quietly growing the code to a version that no longer + // fits an 80×24 window and is dropped from the frame. + for (let attempt = 0; attempt < 50; attempt += 1) { + const url = new LivePiece({ cwd: tmpdir() }).scanUrl; + assert.ok(Buffer.byteLength(url) <= 53, `${url} is too long for a version-3 code`); + const block = qrBlock(url); + assert.equal(block.width, 33); + assert.equal(block.height, 17); + } +}); + +// The phone has to land on the prompt holding the channel as its own word. +// parse.mjs hands everything after `prompt~` to the prompt as ONE parameter, +// and the prompt splits its own arguments on spaces — a tilde glues the channel +// to the command name, so `channel` runs with no argument and joins nothing. +test("the scan URL separates the channel from the command with a space", () => { + const live = new LivePiece({ cwd: tmpdir(), channel: "Ab0-_9Zz" }); + assert.equal(live.scanUrl, "aesthetic.computer/prompt~channel%20Ab0-_9Zz~!autorun"); + const [, command] = live.scanUrl.match(/\/prompt~(.*)~!autorun$/); + assert.deepEqual(decodeURIComponent(command).split(" "), ["channel", "Ab0-_9Zz"]); }); // Read the rendered block back into a grid of dark modules. A code that is @@ -49,7 +67,7 @@ function decodeBlock(block) { test("the rendered code matches the encoder module for module", async () => { const { qrcode, ErrorCorrectLevel } = await import("../src/vendor/qr.mjs"); - const url = "aesthetic.computer/prompt~channel~Ab0-_9Zz~!autorun"; + const url = "aesthetic.computer/prompt~channel%20Ab0-_9Zz~!autorun"; const quiet = 2; const modules = qrcode(url, { errorCorrectLevel: ErrorCorrectLevel.L }).modules; const grid = decodeBlock(qrBlock(url, { quiet })); @@ -101,6 +119,25 @@ test("every runtime carries a blank that names itself", () => { assert.equal(runtimeFor("mjs").routable, true); assert.equal(runtimeFor("lua").routable, false); assert.throws(() => runtimeFor("rust"), /unknown runtime/); + + // The names a person reaches for resolve to the same three runtimes. + assert.equal(runtimeFor("processing").id, "lua"); + assert.equal(runtimeFor("L5").id, "lua"); + assert.equal(runtimeFor("kidlisp").id, "lisp"); + assert.equal(runtimeFor("js").id, "mjs"); + assert.match(runtimeMenu(), /lua \(processing\)/); +}); + +// A live push carries no extension, so the client reads the source to decide +// it is Lua: it must open with a `--` comment and declare setup or draw. The +// Processing blank has to satisfy that on its own or it is compiled as +// JavaScript and the phone never sees the piece. +test("the Processing blank is recognisable as Lua from its source alone", () => { + const source = runtimeFor("processing").blank("movika"); + assert.ok(source.trim().startsWith("--"), "must open with a Lua comment"); + assert.match(source, /(?:^|\n)\s*function\s+(setup|draw)\s*\(/); + // Processing's vocabulary, not Aesthetic Computer's. + assert.doesNotMatch(source, /function\s+paint\s*\(/); }); test("a session mints a blank piece, pushes it, and cleans up after itself", async (context) => { @@ -117,7 +154,7 @@ test("a session mints a blank piece, pushes it, and cleans up after itself", asy }); assert.equal(live.file, join(root, "movika.mjs")); - assert.equal(live.scanUrl, "aesthetic.computer/prompt~channel~Ab0-_9Zz~!autorun"); + assert.equal(live.scanUrl, "aesthetic.computer/prompt~channel%20Ab0-_9Zz~!autorun"); assert.equal(live.publishedUrl("jeffrey"), "https://aesthetic.computer/@jeffrey/movika"); assert.equal(live.publishedUrl(""), ""); @@ -138,6 +175,39 @@ test("a session mints a blank piece, pushes it, and cleans up after itself", asy assert.equal(existsSync(live.file), false); }); +// The session server retains the last message a channel saw and replays it to +// late joiners, so the harness announces a piece once per save and no more. A +// watch that sees no edits must therefore be silent — and must not leave a +// timer running behind it. +test("a piece announces itself once per save, not on a beat", async (context) => { + const root = await workspace(context); + const calls = []; + const live = new LivePiece({ + cwd: root, + slug: "movika", + fetch: async (url, options) => { + calls.push(JSON.parse(options.body)); + return ok(); + }, + }); + live.create(); + live.watch(); + await new Promise((resolve) => setTimeout(resolve, 130)); + + assert.equal(calls.length, 0, "an idle watch does not re-announce"); + + await writeFile(live.file, "// touched\n"); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.equal(calls.length, 1, "a save announces exactly once"); + assert.match(calls[0].source, /touched/); + assert.equal(live.pushes, 1, "and that push is counted as an edit"); + + live.unwatch(); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(calls.length, 1, "stopping the watch leaves no timer behind"); + live.cleanup(); +}); + test("an edited piece is never deleted on the way out", async (context) => { const root = await workspace(context); const live = new LivePiece({ cwd: root, slug: "movika", fetch: async () => ok() }); diff --git a/test/render.test.mjs b/test/render.test.mjs index 4d8c3ae359..574ac33ff0 100644 --- a/test/render.test.mjs +++ b/test/render.test.mjs @@ -62,7 +62,7 @@ test("shows the signed-in handle and the current piece in the header", () => { test("keeps a QR column on the right and narrows the transcript around it", async () => { const { qrBlock } = await import("../src/qr.mjs"); - const qr = qrBlock("aesthetic.computer/prompt~channel~Ab0-_9Zz~!autorun"); + const qr = qrBlock("aesthetic.computer/prompt~channel%20Ab0-_9Zz~!autorun"); const state = { workspace: "/project", mode: "remote", -- 2.51.2 From 6c49a688de316825aafc94608c1672b5c6e8b435 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 18:21:59 -0400 Subject: [PATCH 07/18] The Claude bridge opens on Opus until Fable can be billed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fable is the model this bridge was built for and where it wants to land, but the account cannot currently pay for it: `claude --model claude-fable-5-1` answers `out_of_credits` with the seven-day overage already spent, and `--fallback-model` does not rescue it. The harness was selecting and requesting Fable perfectly well — it failed at the provider, not here — but a default that greets every session with a red error line is not a default. So `claude-opus-5`, which the same account answers on today, and a section in the contract naming the two strings to change when the credits come back. `/model claude-fable-5-1` still reaches for Fable at any moment; the `fable` backend alias is untouched, since it names the bridge and not the model. Verified: doctor reports `default engine bridge: claude (claude-opus-5)`, both bridges ready, and a direct call on that model answers where Fable does not. 33 tests and the CLI suite stay green. --- README.md | 2 +- bin/aesthetic | 4 ++-- docs/local-contract.md | 18 ++++++++++++++++-- src/claude-server.mjs | 8 ++++++-- test/cli.sh | 4 ++-- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a74ebfe56c..a2cce21b4e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Inside the TUI: `/login`, `/logout`, `/whoami`, `/publish [file] [slug]`, Two bridges ship, and either can drive a session: ```sh -ac # claude, on claude-fable-5-1 +ac # claude, on claude-opus-5 ac --backend codex # codex app-server ac --model claude-opus-5 # a different model on the same bridge ``` diff --git a/bin/aesthetic b/bin/aesthetic index 18cf349a17..83a5f4aba9 100755 --- a/bin/aesthetic +++ b/bin/aesthetic @@ -4,7 +4,7 @@ set -euo pipefail VERSION="0.4.0" DEFAULT_BACKEND="${AESTHETIC_CODE_BACKEND:-claude}" -DEFAULT_CLAUDE_MODEL="claude-fable-5-1" +DEFAULT_CLAUDE_MODEL="claude-opus-5" script_path="${BASH_SOURCE[0]}" while [[ -L "$script_path" ]]; do script_directory="$(cd "$(dirname "$script_path")" && pwd -P)" @@ -34,7 +34,7 @@ Each session opens on a new blank piece and shows a QR code that runs it live on a phone. --runtime picks the language of that piece. --backend picks the engine bridge: claude (the default, on -claude-fable-5-1) or codex. --model names the model on that bridge, and +claude-opus-5) or codex. --model names the model on that bridge, and /backend and /model change either one mid-session. EOF } diff --git a/docs/local-contract.md b/docs/local-contract.md index 7f12550b7b..d044d68c48 100644 --- a/docs/local-contract.md +++ b/docs/local-contract.md @@ -40,8 +40,8 @@ the piece on screen is repeatedly leaving the machine. The terminal interface is always Aesthetic Code. Engines are internal bridges, not alternate client interfaces or command shortcuts. -Two bridges exist, both remote: Claude Code in headless stream-json mode -(the default, on `claude-fable-5-1`) and Codex app-server. `--backend` and +Two bridges exist, both remote: Claude Code in headless stream-json mode (the +default, on `claude-opus-5`) and Codex app-server. `--backend` and `/backend` choose between them and `--model` and `/model` name the model. The interface labels remote inference before a prompt is sent. Provider terms govern that traffic, and each bridge signs in with its own vendor's existing @@ -55,6 +55,20 @@ in the session. On both, an approval is answered in this terminal and nowhere else, and an `a` — allow for the session — is held in memory for the life of the session rather than written to a settings file. +### Why the Claude bridge opens on Opus and not Fable + +Fable is the model this bridge was built for and the one it should default to. +The account it runs on cannot currently bill it: `claude --model +claude-fable-5-1` answers `out_of_credits`, with the seven-day overage already +spent, and `--fallback-model` does not rescue that. The harness selects and +requests Fable correctly — it fails at the provider, not here — but a default +that greets every session with a red error line is not a default, so the bridge +opens on `claude-opus-5` instead. + +`/model claude-fable-5-1` still reaches for Fable at any time. When the credits +are back, `DEFAULT_CLAUDE_MODEL` in `src/claude-server.mjs` and the matching +string in `bin/aesthetic` go back to Fable and this section comes out. + ### The sandbox gap on the Claude bridge The two bridges are not equivalent on containment, and the difference is worth diff --git a/src/claude-server.mjs b/src/claude-server.mjs index 4e25475656..52aa4cff5e 100644 --- a/src/claude-server.mjs +++ b/src/claude-server.mjs @@ -36,8 +36,12 @@ import { createInterface } from "node:readline"; const VERSION = "0.4.0"; -// Anthropic's most capable model, and what a session opens on. -export const DEFAULT_CLAUDE_MODEL = "claude-fable-5-1"; +// What a session opens on. Fable is the model this harness was built for and +// where it wants to land, but the account it runs on cannot bill Fable today +// (`out_of_credits`, seven-day overage exhausted), and a default that greets +// every session with a red error line is not a default. Opus until the credits +// come back, then this goes straight back to "claude-fable-5-1". +export const DEFAULT_CLAUDE_MODEL = "claude-opus-5"; // Tools whose work is a file change, and the input field naming the file. The // interface watches these to follow the piece the agent is writing. diff --git a/test/cli.sh b/test/cli.sh index c581240178..797f25f1dd 100755 --- a/test/cli.sh +++ b/test/cli.sh @@ -80,7 +80,7 @@ assert_contains "$output" 'runtime=mjs' # Claude is the default engine bridge, on Fable; Codex stays selectable. assert_contains "$output" 'backend=claude' -assert_contains "$output" 'model=claude-fable-5-1' +assert_contains "$output" 'model=claude-opus-5' output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --backend codex "$WORK_DIR")" assert_contains "$output" 'backend=codex' @@ -96,7 +96,7 @@ fi output="$($CLI doctor)" assert_contains "$output" 'engine bridge claude:' assert_contains "$output" 'engine bridge codex:' -assert_contains "$output" 'default engine bridge: claude (claude-fable-5-1)' +assert_contains "$output" 'default engine bridge: claude (claude-opus-5)' if AESTHETIC_CODE_DRY_RUN=1 "$CLI" --runtime rust "$WORK_DIR" >/dev/null 2>&1; then printf 'Expected an unknown runtime to fail.\n' >&2 -- 2.51.2 From 421c64b0ac3c5752e35c5ca2eb116fced7367132 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 21:02:23 -0400 Subject: [PATCH 08/18] =?UTF-8?q?Measure=20rows=20in=20columns,=20not=20ch?= =?UTF-8?q?aracters=20=E2=80=94=20and=20a=20little=20guy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The QR kept falling apart, and the frame was the reason.** `renderFrame` built every row to be exactly `width` *characters* and never checked *columns*. A row one cell too wide wraps, and one wrapped row scrolls the whole frame — and because the code sits on the bottom transcript rows, the code is what tore. Two independent sources fed it: - The approval row was always exactly two columns too wide. The subject got `width - 31`, but the chrome around it — `ALLOW ` plus the three answers — is 33 columns. Every `ALLOW` prompt overflowed. That margin is measured now rather than counted by hand. - `textWidth` counted code points, so every emoji or CJK glyph in the model's output made its row one column too wide per glyph. Those rows land exactly where the newest transcript text goes, which is the QR band. `fit()` now pads *and* clips every emitted row to exactly `width` columns, copying escape sequences through untouched. That is the structural guarantee: no row can reach the terminal wide enough to wrap, so the frame cannot scroll, whatever arrives. Measured at @jeffrey's own 75x42: the code went from 21 rows of fragments spanning the whole window to 17 rows spanning 33 columns, and the bitmap read back off the screen matches the encoder module for module, 1122 of 1122. `wrapText` also mixed a UTF-16 index with a code-point index and could spin forever on a glyph wider than the column. `redraw()`'s re-entrancy latch is released in `finally`. A single throwing frame used to latch it shut and freeze the screen silently for the rest of the session, because the bridge's error catch swallowed the throw. **And the little guy.** Aesthetic Computer's mark is a period and oskiewar's fighters are round heads on stick bodies, so he is the same creature at terminal scale. He walks in from off-frame while the engine bridge shakes hands — the handshake is most of a second of nothing, so the entrance costs no added delay and the interface replaces him mid-stride when the bridge answers. Then he stands in the footer as one row, `\●/`, in the corner the QR does not want. He does not animate there on purpose: a blinking footer is a full repaint every few seconds for the rest of the session, and the entrance already showed he is alive. `src/mascot.mjs` is pure — ask what he looks like at N milliseconds, get lines back. It reads no clock and draws nothing, so every frame of the performance is testable, and `mascotNextFrameIn` hands the repaint schedule to whoever owns the screen rather than assuming a frame timer. 40 tests and the CLI suite pass. Verified in a real pty at 75x42: the entrance animates across eight frames, the interface arrives with all seventeen QR rows, and the guy is in the corner. --- src/mascot.mjs | 127 +++++++++++++++++++++++++++++ src/render.mjs | 187 ++++++++++++++++++++++++++++++++++++++----- src/tui.mjs | 36 +++++++-- test/mascot.test.mjs | 64 +++++++++++++++ test/render.test.mjs | 36 +++++++++ 5 files changed, 427 insertions(+), 23 deletions(-) create mode 100644 src/mascot.mjs create mode 100644 test/mascot.test.mjs diff --git a/src/mascot.mjs b/src/mascot.mjs new file mode 100644 index 0000000000..7d0c086d4a --- /dev/null +++ b/src/mascot.mjs @@ -0,0 +1,127 @@ +// The little guy. +// +// Aesthetic Computer's mark is a period, and oskiewar's fighters are round +// heads on stick bodies, so the harness's mascot is the same creature at +// terminal scale: a pink dot with a body under it. He walks in from the left +// edge when a session opens, waves once he arrives, and then stands in the +// bottom-left corner for the rest of the session — the opposite corner from +// the QR code, which owns the right. +// +// Everything here is pure. A caller asks what the guy looks like at a given +// number of milliseconds and gets back lines; nothing schedules, nothing draws, +// and nothing reads a clock. That keeps him testable frame by frame and keeps +// the decision about how often to repaint with the interface that owns the +// screen. + +export const MASCOT_WIDTH = 3; +export const MASCOT_HEIGHT = 3; + +// Three rows, three columns, always. A sprite that changes size shifts the +// text beside it, and a mascot that makes the transcript jitter is a bug +// wearing a costume. +const POSES = { + stand: [" ● ", "╱│╲", "╱ ╲"], + // Mid-stride: the legs close and the arms swing back, which reads as motion + // even at three rows because the silhouette changes rather than the position + // of one pixel. + step: [" ● ", "╲│╱", " ╽ "], + wave: ["╲● ", " │╱", "╱ ╲"], + // A blink is the smallest thing that says the drawing is alive. The head + // keeps its width so the body does not appear to shift under it. + blink: [" ▪ ", "╱│╲", "╱ ╲"], +}; + +// Milliseconds. The walk is brisk enough that nobody waits for it and slow +// enough to read as walking; the wave lands after he stops, because waving +// mid-stride looks like falling over. +const STEP_MS = 160; +const WALK_MS = 1120; +const WAVE_MS = 720; +const BLINK_EVERY_MS = 5200; +const BLINK_MS = 140; + +// Where he comes to rest, in columns from the left edge of the frame. One +// column of margin so he is not welded to the border. +export const MASCOT_REST_X = 1; + +// How far off the left edge he starts. He walks in from outside the frame +// rather than fading in, so the first thing the interface does is move. +const ENTER_X = -MASCOT_WIDTH; + +function lerp(from, to, t) { + return Math.round(from + (to - from) * t); +} + +// The whole performance as a function of time. `elapsed` is milliseconds since +// the session opened. +export function mascotAt(elapsed = 0) { + const ms = Math.max(0, Number(elapsed) || 0); + + if (ms < WALK_MS) { + const t = ms / WALK_MS; + return { + phase: "walking", + x: lerp(ENTER_X, MASCOT_REST_X, t), + // Alternating stride. Two poses is enough: the eye fills in the rest. + lines: Math.floor(ms / STEP_MS) % 2 ? POSES.step : POSES.stand, + }; + } + + if (ms < WALK_MS + WAVE_MS) { + const beat = Math.floor((ms - WALK_MS) / (WAVE_MS / 3)) % 2; + return { + phase: "waving", + x: MASCOT_REST_X, + lines: beat ? POSES.wave : POSES.stand, + }; + } + + // Standing, with a blink often enough to notice and rarely enough to ignore. + const since = (ms - WALK_MS - WAVE_MS) % BLINK_EVERY_MS; + return { + phase: "idle", + x: MASCOT_REST_X, + lines: since < BLINK_MS ? POSES.blink : POSES.stand, + }; +} + +// When the entrance is over and he is simply standing there. +export const MASCOT_SETTLED_MS = WALK_MS + WAVE_MS; + +// True while the guy is still doing something worth repainting for. Once he is +// standing, the interface only needs to repaint him on the blink, so the caller +// can drop from an animation timer to a lazy one. +export function mascotIsAnimating(elapsed = 0) { + return (Number(elapsed) || 0) < MASCOT_SETTLED_MS; +} + +// When the caller should next repaint, in milliseconds from `elapsed`. During +// the walk that is the next stride; afterwards it is the next blink edge, which +// is seconds away — so an idle session costs two repaints per blink rather than +// a frame timer running forever. +export function mascotNextFrameIn(elapsed = 0) { + const ms = Math.max(0, Number(elapsed) || 0); + if (ms < WALK_MS) return STEP_MS - (ms % STEP_MS); + if (ms < WALK_MS + WAVE_MS) { + const unit = WAVE_MS / 3; + return unit - ((ms - WALK_MS) % unit); + } + const since = (ms - WALK_MS - WAVE_MS) % BLINK_EVERY_MS; + return since < BLINK_MS ? BLINK_MS - since : BLINK_EVERY_MS - since; +} + +// The sprite as rows of {text, tone} so the renderer can paint the head in the +// mark's pink and the body in the interface's soft purple without this module +// knowing anything about colour. +export function mascotRows(elapsed = 0) { + const { lines, x, phase } = mascotAt(elapsed); + return { + phase, + x, + rows: lines.map((text, index) => ({ + text, + // Row zero carries the head, which is the mark itself. + tone: index === 0 ? "handle" : "soft", + })), + }; +} diff --git a/src/render.mjs b/src/render.mjs index 0eee4158b7..71e77f5234 100644 --- a/src/render.mjs +++ b/src/render.mjs @@ -3,6 +3,8 @@ // The palette is the Aesthetic Computer prompt's dark scheme (disks/prompt.mjs // `scheme.dark`): purple ground, pink prompt block, orange highlight, magenta // handle, light-purple secondary text. +import { MASCOT_HEIGHT, mascotAt } from "./mascot.mjs"; + const ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; @@ -67,15 +69,57 @@ export function cleanText(value) { .replace(/\t/g, " "); } +// Cells wide, not characters long. A terminal gives an emoji or a CJK glyph +// two columns and a combining mark none, so counting characters measures a row +// short — and a row measured short overflows the window, wraps, and scrolls +// the whole frame up. The QR code lives on the bottom rows, so it is the first +// thing that goes. Erring wide is therefore the safe direction: an over-counted +// row is merely a space short, an under-counted one destroys the frame. +const ZERO = [ + [0x0300, 0x036f], [0x200b, 0x200f], [0x20d0, 0x20f0], [0xfe00, 0xfe0f], +]; +const WIDE = [ + [0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], + [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615], + [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], + [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], + [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], + [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], + [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], + [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf], + [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x2e80, 0x303e], + [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], + [0xa960, 0xa97f], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19], + [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f000, 0x1faff], + [0x20000, 0x3fffd], +]; +const within = (code, ranges) => ranges.some(([low, high]) => code >= low && code <= high); + +function charWidth(character) { + const code = character.codePointAt(0); + if (within(code, ZERO)) return 0; + return within(code, WIDE) ? 2 : 1; +} + export function textWidth(value) { - return Array.from(cleanText(value)).length; + let width = 0; + for (const character of cleanText(value)) width += charWidth(character); + return width; } export function clipText(value, width) { const characters = Array.from(cleanText(value)); - if (characters.length <= width) return characters.join(""); + if (textWidth(characters.join("")) <= width) return characters.join(""); if (width <= 1) return "…".slice(0, width); - return `${characters.slice(0, width - 1).join("")}…`; + let used = 0; + const kept = []; + for (const character of characters) { + const columns = charWidth(character); + if (used + columns > width - 1) break; + kept.push(character); + used += columns; + } + return `${kept.join("")}…`; } export function wrapText(value, width) { @@ -91,11 +135,24 @@ export function wrapText(value, width) { let remaining = paragraph; while (textWidth(remaining) > safeWidth) { const characters = Array.from(remaining); - const window = characters.slice(0, safeWidth + 1).join(""); - const breakAt = window.lastIndexOf(" "); - const count = breakAt > Math.floor(safeWidth * 0.45) ? breakAt : safeWidth; - output.push(characters.slice(0, count).join("").trimEnd()); - remaining = characters.slice(count).join("").trimStart(); + // How many characters fill one row, and the last space inside them — a + // word break is taken only if it leaves the row better than half full. + // At least one character always goes, or a glyph wider than the column + // would spin here forever. + let used = 0; + let count = 0; + let breakAt = -1; + while (count < characters.length) { + const columns = charWidth(characters[count]); + if (used + columns > safeWidth) break; + if (characters[count] === " ") breakAt = count; + used += columns; + count += 1; + } + if (count === 0) count = 1; + const at = breakAt > Math.floor(safeWidth * 0.45) ? breakAt : count; + output.push(characters.slice(0, at).join("").trimEnd()); + remaining = characters.slice(at).join("").trimStart(); } output.push(remaining); } @@ -109,8 +166,37 @@ function paint(enabled, tone, value) { return `${tones}${value}${color.reset}${color.ground}`; } -function pad(value, width) { - return value + " ".repeat(Math.max(0, width - textWidth(value))); +// Make a painted row exactly `width` columns: pad it short, clip it long, and +// carry its colour either way. Every row of every frame goes through this, so +// no row can reach the terminal wide enough to wrap — and since one wrapped +// row scrolls the entire frame, this is what keeps the QR code on screen. +const SPANS = /(\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)))/; + +function fit(value, width) { + const parts = String(value ?? "").split(SPANS); + let out = ""; + let used = 0; + let full = false; + for (let index = 0; index < parts.length; index += 1) { + if (!parts[index]) continue; + if (index % 2 === 1) { + out += parts[index]; // An escape sequence costs no columns. + continue; + } + if (full) continue; + for (const character of parts[index]) { + const columns = charWidth(character); + // A double-width glyph on the last column is dropped rather than let + // through: the terminal would wrap it whole onto the next row. + if (used + columns > width) { + full = true; + break; + } + out += character; + used += columns; + } + } + return out + " ".repeat(Math.max(0, width - used)); } const STYLES = { @@ -141,6 +227,60 @@ function statusTone(status) { return "soft"; } +// The entrance. Opening a session means waiting on the engine bridge — the +// handshake is most of a second — so the little guy walks in across that wait +// rather than adding one of his own. When the bridge answers, the interface +// replaces this frame mid-stride, which is the right time for him to stop. +export function renderBoot(elapsed = 0, columns = 80, rows = 24, useColor = true) { + const width = Math.max(32, columns); + const height = Math.max(10, rows); + const ground = useColor ? color.ground : ""; + const reset = useColor ? color.reset : ""; + + const { lines: sprite, x } = mascotAt(elapsed); + const title = "AESTHETIC CODE"; + // He walks along a baseline under the title, indented to the same margin the + // interface uses so the two frames agree about where the left edge is. + const floor = Math.floor(height / 2); + const top = floor - MASCOT_HEIGHT; + + const rows_ = []; + for (let row = 0; row < height; row += 1) { + if (row === top - 2) { + const pad = Math.max(1, Math.floor((width - textWidth(title)) / 2)); + rows_.push(`${" ".repeat(pad)}${paint(useColor, "bold text", title)}`); + continue; + } + if (row === top - 1) { + const label = "connecting"; + const pad = Math.max(1, Math.floor((width - textWidth(label)) / 2)); + rows_.push(`${" ".repeat(pad)}${paint(useColor, "muted", label)}`); + continue; + } + const band = row - top; + if (band >= 0 && band < MASCOT_HEIGHT) { + // Clip on the left: he starts outside the frame and walks in, so early + // frames show only his trailing edge. + const glyphs = Array.from(sprite[band]); + let line = ""; + for (let index = 0; index < glyphs.length; index += 1) { + const column = x + index; + if (column < 0) continue; + if (line === "") line = " ".repeat(column + 1); + line += glyphs[index]; + } + rows_.push(paint(useColor, band === 0 ? "handle" : "soft", line)); + continue; + } + rows_.push(""); + } + + return rows_ + .slice(0, height) + .map((line) => `${ground}${fit(line, width)}${reset}`) + .join("\n"); +} + export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const width = Math.max(32, columns); const height = Math.max(10, rows); @@ -190,7 +330,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { while (visible.length < transcriptRows) visible.unshift(""); const body = visible.map((line, index) => { - const row = ` ${pad(line, contentWidth)}`; + const row = ` ${fit(line, contentWidth)}`; if (!qr) return row; const band = index - (transcriptRows - qr.height); return band >= 0 ? `${row} ${qr.lines[band]}` : row; @@ -198,7 +338,12 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { let prompt; if (state.approval) { - const subject = clipText(state.approval.subject || "requested action", Math.max(4, width - 31)); + // The subject gets whatever the label and the three answers leave, measured + // rather than guessed: a hand-counted margin was two columns short, and the + // row it overflowed wrapped every approval into a scroll. + const choices = " y once a session n deny"; + const room = Math.max(4, width - textWidth("ALLOW ") - textWidth(choices)); + const subject = clipText(state.approval.subject || "requested action", room); prompt = `${paint(useColor, "highlight bold", "ALLOW")} ${subject} ${paint(useColor, "bold", "y")} once ${paint(useColor, "bold", "a")} session ${paint(useColor, "bold", "n")} deny`; } else { const input = Array.from(cleanText(state.input || "")); @@ -215,14 +360,20 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { } const rule = paint(useColor, "muted", "─".repeat(width)); - const help = paint( - useColor, - "muted", - clipText(state.busy ? " ctrl-c interrupt" : " /help · /login · /publish · /qr · ctrl-c quit", width), - ); + // The little guy keeps the far corner from the QR code. He is one row and he + // does not move: an animated footer costs a full repaint every few seconds + // for the rest of the session, and the entrance already showed he is alive. + const guy = `${paint(useColor, "soft", "\\")}${paint(useColor, "handle", "\u25cf")}${paint(useColor, "soft", "/")}`; + const helpText = state.busy + ? " ctrl-c interrupt" + : " /help \u00b7 /login \u00b7 /publish \u00b7 /qr \u00b7 ctrl-c quit"; + const help = + width >= 23 + ? ` ${guy}${paint(useColor, "muted", clipText(helpText, width - 5))}` + : paint(useColor, "muted", clipText(helpText, width)); const lines = [header, pathLine, ...body, rule, prompt, help]; return lines .slice(0, height) - .map((line) => `${ground}${pad(line, width)}${reset}`) + .map((line) => `${ground}${fit(line, width)}${reset}`) .join("\n"); } diff --git a/src/tui.mjs b/src/tui.mjs index b49c0dd984..266b73ce6a 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -7,7 +7,8 @@ import { backendFor, backendMenu, DEFAULT_BACKEND } from "./backends.mjs"; import { LivePiece } from "./live.mjs"; import { publishPiece } from "./publish.mjs"; import { qrBlock } from "./qr.mjs"; -import { cleanText, renderFrame } from "./render.mjs"; +import { cleanText, renderBoot, renderFrame } from "./render.mjs"; +import { mascotNextFrameIn } from "./mascot.mjs"; import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; @@ -135,12 +136,17 @@ function updateEntry(id, kind, text) { } } +// The guard keeps a redraw from re-entering itself; `finally` is what keeps a +// single bad frame from latching it shut and freezing the screen for good. function redraw() { if (closing || drawing) return; drawing = true; - const frame = renderFrame(state, process.stdout.columns, process.stdout.rows, process.env.NO_COLOR !== "1"); - process.stdout.write(`\x1b[H\x1b[2J${frame}`); - drawing = false; + try { + const frame = renderFrame(state, process.stdout.columns, process.stdout.rows, process.env.NO_COLOR !== "1"); + process.stdout.write(`\x1b[H\x1b[2J${frame}`); + } finally { + drawing = false; + } } function finish(code = 0) { @@ -700,9 +706,28 @@ live.watch(liveError); state.piece = `${live.slug}${live.runtime.extension}`; refreshQr(); -redraw(); +// The entrance plays across the bridge handshake instead of in front of it. +// The handshake is most of a second of nothing; the little guy walks in over +// it, and the interface replaces him mid-stride the moment the bridge answers. +const bootAt = Date.now(); +let bootTimer = null; +function bootFrame() { + if (closing || drawing) return; + const elapsed = Date.now() - bootAt; + const frame = renderBoot(elapsed, process.stdout.columns, process.stdout.rows, + process.env.NO_COLOR !== "1"); + process.stdout.write(`\x1b[H\x1b[2J${frame}`); + bootTimer = setTimeout(bootFrame, mascotNextFrameIn(elapsed)); + bootTimer.unref?.(); +} +function bootDone() { + clearTimeout(bootTimer); + bootTimer = null; +} +bootFrame(); try { const connection = await engine.connect(); + bootDone(); slabSession.connected(connection.thread.id); state.status = "ready"; state.model = connection?.model || model; @@ -722,6 +747,7 @@ try { await submitInput(); } } catch (error) { + bootDone(); state.status = "offline"; addEntry("error", errorText(error)); redraw(); diff --git a/test/mascot.test.mjs b/test/mascot.test.mjs new file mode 100644 index 0000000000..56da0388ec --- /dev/null +++ b/test/mascot.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MASCOT_HEIGHT, + MASCOT_REST_X, + MASCOT_SETTLED_MS, + MASCOT_WIDTH, + mascotAt, + mascotIsAnimating, + mascotNextFrameIn, + mascotRows, +} from "../src/mascot.mjs"; + +// A sprite that changes size shifts whatever sits beside it, so the size is +// the one thing that must hold across every frame of the performance. +test("the guy is the same size in every pose", () => { + for (let ms = 0; ms < 12000; ms += 37) { + const { lines } = mascotAt(ms); + assert.equal(lines.length, MASCOT_HEIGHT, `height at ${ms}ms`); + for (const line of lines) + assert.equal(Array.from(line).length, MASCOT_WIDTH, `width at ${ms}ms: ${line}`); + } +}); + +test("he walks in from off-frame and stops in the corner", () => { + assert.ok(mascotAt(0).x + MASCOT_WIDTH <= 0, "starts fully outside the frame"); + assert.equal(mascotAt(0).phase, "walking"); + + let previous = mascotAt(0).x; + for (let ms = 0; ms < 1120; ms += 40) { + const { x } = mascotAt(ms); + assert.ok(x >= previous, `never walks backwards (${ms}ms)`); + previous = x; + } + assert.equal(mascotAt(MASCOT_SETTLED_MS).x, MASCOT_REST_X, "comes to rest"); +}); + +test("he waves after he arrives, not while walking", () => { + const poses = new Set(); + for (let ms = 1120; ms < 1840; ms += 20) poses.add(mascotAt(ms).lines.join("|")); + assert.ok(poses.size > 1, "the wave actually animates"); + assert.equal(mascotAt(1400).phase, "waving"); + assert.equal(mascotAt(1400).x, MASCOT_REST_X, "stands still to wave"); +}); + +test("he settles, then only blinks", () => { + assert.ok(mascotIsAnimating(500)); + assert.ok(!mascotIsAnimating(MASCOT_SETTLED_MS + 1)); + assert.equal(mascotAt(3000).phase, "idle"); + + // An idle session must not ask for a repaint every frame — that is a mascot + // spinning a CPU. Seconds between blinks, not milliseconds. + assert.ok(mascotNextFrameIn(3000) > 1000, "idle repaints are seconds apart"); + for (let ms = 0; ms < 12000; ms += 53) + assert.ok(mascotNextFrameIn(ms) > 0, `always schedules forward at ${ms}ms`); +}); + +test("the head is the mark and carries its own colour", () => { + const { rows } = mascotRows(3000); + assert.equal(rows.length, MASCOT_HEIGHT); + assert.equal(rows[0].tone, "handle", "the head is painted in the mark's pink"); + assert.ok(rows.slice(1).every((row) => row.tone === "soft")); + assert.match(rows[0].text, /[●▪]/, "the head is a dot"); +}); diff --git a/test/render.test.mjs b/test/render.test.mjs index 574ac33ff0..6bbe8be6d1 100644 --- a/test/render.test.mjs +++ b/test/render.test.mjs @@ -94,6 +94,42 @@ test("keeps a QR column on the right and narrows the transcript around it", asyn } }); +test("no state can make a row wider than the window", async () => { + const { qrBlock } = await import("../src/qr.mjs"); + const qr = qrBlock("aesthetic.computer/prompt~channel%20Ab0-_9Zz~!autorun"); + const path = "/private/tmp/claude-501/-Users-jas/8e2ce643-970e-478d-bf06-4e3e19671f15/scratchpad/puka.mjs"; + const base = { workspace: path, mode: "remote", status: "ready", account: "@tester", piece: "puka.mjs", input: "", qr, entries: [] }; + // One wrapped row scrolls the whole frame, and the QR code sits on the rows + // that go first — so every one of these has to come back exactly as wide as + // the window, escape codes and double-width glyphs and all. + const states = [ + base, + { ...base, status: "approval", approval: { subject: path } }, + { ...base, status: "approval", approval: { subject: "x".repeat(400) } }, + { ...base, entries: [{ id: "e", kind: "assistant", text: "Done ✅ it runs 🚀 now — check it ✨" }] }, + { ...base, entries: [{ id: "e", kind: "assistant", text: "这是一个测试 これはテストです 이것은 테스트입니다" }] }, + { ...base, entries: [{ id: "e", kind: "command", text: `${"█".repeat(120)}\n${"—".repeat(200)}` }] }, + { ...base, input: `${"🌈".repeat(60)}?`, cursor: 61 }, + ]; + for (const state of states) { + for (const [columns, rows] of [[75, 42], [100, 40], [80, 24], [57, 24], [40, 20], [32, 10]]) { + const frame = renderFrame(state, columns, rows, true); + assert.equal(frame.split("\n").length, rows); + for (const row of frame.split("\n")) assert.equal(textWidth(row), columns); + } + } +}); + +test("measures a row in terminal cells, not characters", () => { + assert.equal(textWidth("ok"), 2); + assert.equal(textWidth("🌈"), 2, "an emoji takes two columns"); + assert.equal(textWidth("测"), 2, "so does a CJK glyph"); + assert.equal(textWidth("é"), 1, "a combining mark takes none"); + assert.equal(textWidth("▀─·—…"), 5, "the interface's own glyphs stay narrow"); + assert.deepEqual(wrapText("🌈🌈🌈", 4), ["🌈🌈", "🌈"]); + assert.equal(wrapText("🌈🌈", 1).filter(Boolean).length, 2, "a glyph wider than the column still advances"); +}); + test("renders approvals inside the interface", () => { const frame = renderFrame( { -- 2.51.2 From c488724db492e2e1dd94eb77ccdb197ec64039ae Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 21:32:35 -0400 Subject: [PATCH 09/18] The code asks for the rows it uses, not two more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @jeffrey's window is 23 rows, and the code did not appear in it. Nothing was broken: the gate wanted `transcriptRows >= height + 2`, which at 23 rows is 18 available against 19 asked for. One row short, and the reason was a margin that was never used for anything — the code occupies the bottom `height` rows of the transcript and needs no clearance above them. So it asks for `height` now. The floor moves from 24 rows to 22, where the transcript is entirely code band on the right and still ordinary text on the left. Below that it falls back to the scan URL as before. Also: the entrance bailed without rescheduling when it found a redraw in flight, so a single collision stopped the walk mid-stride for good instead of dropping one frame. Verified in a pty at 99x23 (his live window), 75x42 and 99x22: seventeen rows of code in every one. 40 tests and the CLI suite pass. --- src/render.mjs | 5 ++++- src/tui.mjs | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/render.mjs b/src/render.mjs index 71e77f5234..261159b0ea 100644 --- a/src/render.mjs +++ b/src/render.mjs @@ -320,8 +320,11 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // narrowed rather than overdrawn. A code is an image, not text: it needs its // own black on white to be scannable, so a window with colour switched off or // too little room shows the scan URL instead and drops the code. + // The code needs the rows it occupies and not one more. An earlier `+ 2` + // asked for breathing room it never used, which put the cliff at 24 rows and + // hid the code from a 23-row window for no reason a reader could see. const qr = - useColor && state.qr && width >= state.qr.width + 24 && transcriptRows >= state.qr.height + 2 + useColor && state.qr && width >= state.qr.width + 24 && transcriptRows >= state.qr.height ? state.qr : null; const contentWidth = qr ? width - qr.width - 2 : width - 2; diff --git a/src/tui.mjs b/src/tui.mjs index 266b73ce6a..41c901dce0 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -712,8 +712,15 @@ refreshQr(); const bootAt = Date.now(); let bootTimer = null; function bootFrame() { - if (closing || drawing) return; + if (closing) return; const elapsed = Date.now() - bootAt; + // A frame skipped because a redraw is in flight must still schedule the next + // one, or the entrance stops mid-stride and never resumes. + if (drawing) { + bootTimer = setTimeout(bootFrame, mascotNextFrameIn(elapsed)); + bootTimer.unref?.(); + return; + } const frame = renderBoot(elapsed, process.stdout.columns, process.stdout.rows, process.env.NO_COLOR !== "1"); process.stdout.write(`\x1b[H\x1b[2J${frame}`); -- 2.51.2 From 129e13c4cf62afdb05e5109e29fe4bb0452ad569 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 21:37:49 -0400 Subject: [PATCH 10/18] =?UTF-8?q?/open=20=E2=80=94=20the=20code=20is=20for?= =?UTF-8?q?=20a=20phone,=20this=20is=20for=20the=20machine=20you=20are=20o?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QR takes seventeen rows because Terminal.app cannot draw a picture. It has no sixel, no iTerm2 inline images and no Kitty graphics protocol, so a half block per two modules is the finest raster available and 53 bytes of scan URL is a version-3 code however it is drawn. So: `/open`. Same URL the phone gets — same channel, same autorun — handed to the system browser, where the piece opens and reloads on every save exactly as it does on the phone. Verified the URL it builds answers HTTP 200. The child is detached with its stdio dropped on purpose. A browser launcher that inherits this terminal can print into the frame, and anything printed into the frame scrolls it — which is the same class of bug that was tearing the QR apart an hour ago. `/qr` still hides the code for anyone who would rather have the rows back. --- src/render.mjs | 2 +- src/tui.mjs | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/render.mjs b/src/render.mjs index 261159b0ea..2b8cd1f4d1 100644 --- a/src/render.mjs +++ b/src/render.mjs @@ -369,7 +369,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const guy = `${paint(useColor, "soft", "\\")}${paint(useColor, "handle", "\u25cf")}${paint(useColor, "soft", "/")}`; const helpText = state.busy ? " ctrl-c interrupt" - : " /help \u00b7 /login \u00b7 /publish \u00b7 /qr \u00b7 ctrl-c quit"; + : " /help \u00b7 /login \u00b7 /publish \u00b7 /open \u00b7 /qr \u00b7 ctrl-c quit"; const help = width >= 23 ? ` ${guy}${paint(useColor, "muted", clipText(helpText, width - 5))}` diff --git a/src/tui.mjs b/src/tui.mjs index 41c901dce0..a1f36e01b6 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { spawn } from "node:child_process"; import path from "node:path"; import process from "node:process"; import { ACSession } from "./ac-session.mjs"; @@ -499,7 +500,7 @@ async function submitInput() { if (command === "/help") { addEntry( "notice", - "/login · /logout · /whoami · /publish [file] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", + "/login · /logout · /whoami · /publish [file] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -546,6 +547,32 @@ async function submitInput() { } 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 + // browser here and updates on every save exactly as the phone does. + const url = `https://${live.scanUrl}`; + const opener = process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "explorer" + : "xdg-open"; + try { + // Detached and fully redirected: a browser launcher that inherits this + // terminal can print into the frame, and anything printed into the + // frame scrolls it. + const child = spawn(opener, [url], { stdio: "ignore", detached: true }); + child.on("error", (error) => { + addEntry("error", `Could not open a browser: ${errorText(error)}`); + redraw(); + }); + child.unref(); + addEntry("notice", `Opening ${url}`); + } catch (error) { + addEntry("error", `Could not open a browser: ${errorText(error)}`); + } + return redraw(); + } if (command === "/qr") { state.showQr = !state.showQr; refreshQr(); -- 2.51.2 From 95ec37fd83bdb98168485084b702e8b3b21b533e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 21:46:39 -0400 Subject: [PATCH 11/18] Tell the rock where the piece lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt rock in the menu bar is a better home for a QR than the transcript is. It is real pixels on a surface you can hold a phone up to, it costs the terminal nothing, and Terminal.app cannot draw a picture inline anyway — no sixel, no inline images, no Kitty protocol — so seventeen rows of half-blocks was the best the transcript could ever do. So the marker this session publishes now carries `piece` and `scan_url` alongside what it already advertised, and `refreshQr` keeps them current: the address changes when the piece is renamed or its runtime retargeted, and the rock should never point at something that stopped running. `/qr` still hides the code in the transcript and deliberately does not blank the rock. They are different surfaces with different room; wanting the rows back in here says nothing about out there. The menu bar's half of this — drawing the code on the rock — is a change to SlabMenubar in the aesthetic-computer repo. --- src/slab-session.mjs | 16 ++++++++++++++++ src/tui.mjs | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/src/slab-session.mjs b/src/slab-session.mjs index 770aa0f7fe..420a795c2b 100644 --- a/src/slab-session.mjs +++ b/src/slab-session.mjs @@ -59,6 +59,12 @@ export class SlabSession { agent_pid: pid, agent_type: "aesthetic-code", handle: "", + // The piece this session is writing, and the address a phone reaches it + // at. The menubar draws these as a scannable code on the rock, which is + // where a QR wants to be: real pixels on a surface you can hold a camera + // up to, rather than seventeen rows of half-blocks inside the transcript. + piece: "", + scan_url: "", provider_agent_type: "codex", provider_session_id: "", updated: now(), @@ -89,6 +95,16 @@ export class SlabSession { this.#update({ handle: String(handle || "").replace(/^@/, "") }); } + // The live piece and its scan address. Called whenever either changes — a + // session renames its piece, or switches runtime — so the code on the rock + // always points at what is actually running. + live(piece = "", scanUrl = "") { + this.#update({ + piece: String(piece || ""), + scan_url: String(scanUrl || ""), + }); + } + working(prompt = "") { this.#remove(this.awaiting); this.#touch(this.running); diff --git a/src/tui.mjs b/src/tui.mjs index a1f36e01b6..ef6d4ae06c 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -183,6 +183,10 @@ function liveError(error) { function refreshQr() { state.qr = state.showQr ? qrBlock(live.scanUrl) : null; + // The rock in the menu bar carries the same address. `/qr` hides the code in + // here, not out there — the rock is a different surface with its own room, + // and hiding one is no reason to blank the other. + slabSession.live(`${live.slug}${live.runtime.extension}`, live.scanUrl); } function itemSummary(item) { -- 2.51.2 From dac510b1331bd4acf4e7d8bdb95bd7d5bdca84c8 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 22:03:40 -0400 Subject: [PATCH 12/18] The resume test was racing its own fake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resuming names the thread instead of minting one` failed about half the time under the full suite and never in isolation. Both faults were in the test scaffolding; the bridge was right the whole way through. `argv[argv.indexOf("--session-id") + 1]` reads `argv[0]` when the flag is absent, and `argv[0]` is `--print` — truthy, so the `--resume` fallback after it never ran. The fake announced `session_id: "--print"` in its `init`, and `ClaudeServer` correctly adopted what it was told. The same `indexOf + 1` trap sat in the test's own helper, which is why the failure named a plausible wrong cause: `--resume` looked absent when it was always present. Per-spawn recording settled that — exactly one spawn per run, every one carrying `--resume`. The race underneath: `connect()` resolves on the handshake response and the fake's `init` arrives in the same stdout chunk, so reading `engine.threadId` straight after `connect()` was racing readline. Alone the stale-but-correct id usually survived; under load the scheduling flipped. The test now takes a turn before reading the id back, so `init` is guaranteed processed — which also means it can finally catch this class of regression, having previously been able to pass without ever seeing the message that revises the id. Both argv tests now assert exactly one spawn, so a relaunch during `connect()` fails loudly instead of quietly overwriting the record. Mutation-verified: putting the one-line bug back fails 3/3 in isolation, where before it only showed under the suite and only half the time. Five consecutive full runs green. --- test/claude-server.test.mjs | 42 ++++++++++++++++++++++++++++++++----- test/fake-claude-cli.mjs | 20 +++++++++++++++--- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/test/claude-server.test.mjs b/test/claude-server.test.mjs index 823557c0ce..3f38c7bcc2 100644 --- a/test/claude-server.test.mjs +++ b/test/claude-server.test.mjs @@ -9,6 +9,21 @@ import { ClaudeServer, DEFAULT_CLAUDE_MODEL } from "../src/claude-server.mjs"; const directory = path.dirname(fileURLToPath(import.meta.url)); const fake = path.join(directory, "fake-claude-cli.mjs"); +// A flag's value, or undefined when the flag is not there. Reaching for +// `argv.indexOf(name) + 1` instead reads argv[0] on a missing flag and +// compares against "--print", which fails in a way that names the wrong cause. +function flagIn(argv, name) { + const index = argv.indexOf(name); + return index < 0 ? undefined : argv[index + 1]; +} + +// Every spawn that recorded itself, oldest first. +function launches(argvFile) { + const raw = readFileSync(argvFile, "utf8"); + const lines = raw.split("\n").filter(Boolean).map((line) => JSON.parse(line)); + return { raw, lines, argvs: lines.map((line) => line.argv) }; +} + function scratch() { const root = mkdtempSync(path.join(tmpdir(), "aesthetic-claude-")); return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; @@ -110,8 +125,10 @@ test("the launch carries the approval contract and the workspace", async (t) => environment: { FAKE_CLAUDE_ARGV: argvFile }, }); await engine.connect(); - const argv = JSON.parse(readFileSync(argvFile, "utf8")); - const flag = (name) => argv[argv.indexOf(name) + 1]; + const record = launches(argvFile); + assert.equal(record.lines.length, 1, `expected one spawn, got:\n${record.raw}`); + const argv = record.argvs[0]; + const flag = (name) => flagIn(argv, name); assert.equal(flag("--model"), DEFAULT_CLAUDE_MODEL); // Without this the CLI has nobody to ask and denies every prompt itself. @@ -134,11 +151,26 @@ test("resuming names the thread instead of minting one", async (t) => { const argvFile = path.join(root, "argv.json"); const threadId = "00000000-0000-0000-0000-000000000001"; const engine = bridge(t, { resumeThreadId: threadId, environment: { FAKE_CLAUDE_ARGV: argvFile } }); + const completed = new Promise((resolve) => { + engine.on("notification", ({ method, params }) => { + if (method === "turn/completed") resolve(params.turn.status); + }); + }); + engine.on("request", (request) => engine.respond(request.id, { decision: "accept" })); + await engine.connect(); - const argv = JSON.parse(readFileSync(argvFile, "utf8")); - assert.equal(argv[argv.indexOf("--resume") + 1], threadId); + const record = launches(argvFile); + assert.equal(record.lines.length, 1, `expected one spawn, got:\n${record.raw}`); + const argv = record.argvs[0]; + assert.equal(flagIn(argv, "--resume"), threadId, `argv was:\n${record.raw}`); assert.ok(!argv.includes("--session-id")); - assert.equal(engine.threadId, threadId); + + // Take a turn before reading the id back. `system/init` arrives with the + // turn, and it is what revises the thread id — asserting straight after + // connect() races that message and can pass without ever seeing it. + await engine.startTurn("carry on"); + assert.equal(await completed, "completed"); + assert.equal(engine.threadId, threadId, "a resumed thread keeps its id"); cleanup(); }); diff --git a/test/fake-claude-cli.mjs b/test/fake-claude-cli.mjs index 6a5ae0b0fc..3c9d7a611c 100644 --- a/test/fake-claude-cli.mjs +++ b/test/fake-claude-cli.mjs @@ -1,10 +1,24 @@ // A stand-in for `claude` in headless stream-json mode: enough of the // protocol to drive one turn that writes a file and asks to do it. -import { writeFileSync } from "node:fs"; +import { appendFileSync, writeFileSync } from "node:fs"; import { createInterface } from "node:readline"; const argv = process.argv.slice(2); -if (process.env.FAKE_CLAUDE_ARGV) writeFileSync(process.env.FAKE_CLAUDE_ARGV, JSON.stringify(argv)); +// One line per spawn, appended: a second launch must show up rather than +// overwrite the first. +if (process.env.FAKE_CLAUDE_ARGV) { + appendFileSync( + process.env.FAKE_CLAUDE_ARGV, + `${JSON.stringify({ pid: process.pid, ppid: process.ppid, at: Date.now(), argv })}\n`, + ); +} + +// A missing flag has no value. `argv.indexOf(name) + 1` reads argv[0] when the +// flag is absent, which quietly hands back "--print" instead of nothing. +function flag(name) { + const index = argv.indexOf(name); + return index < 0 ? "" : argv[index + 1] || ""; +} function send(message) { process.stdout.write(`${JSON.stringify(message)}\n`); @@ -14,7 +28,7 @@ const file = "/tmp/piece.mjs"; const toolUseId = "toolu_1"; // The real CLI honours --session-id and keeps a resumed id, and reports it // back on every turn's init. -const sessionId = argv[argv.indexOf("--session-id") + 1] || argv[argv.indexOf("--resume") + 1] || "session-1"; +const sessionId = flag("--session-id") || flag("--resume") || "session-1"; createInterface({ input: process.stdin }).on("line", (line) => { const message = JSON.parse(line); -- 2.51.2 From 9a4df0a60b9b8d76770493adfc22c677f1b0b4d3 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 10 Sep 2026 08:06:10 -0400 Subject: [PATCH 13/18] The transcript stops drawing the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt rock in the menu bar draws this session's address at real pixel resolution now, so the seventeen rows the transcript spent on a coarser copy of the same thing are better spent on the transcript. Two codes for one address, one of them a third of the window, was one too many. `/qr` still brings it back, and that matters: on a machine with no Slab menu bar the code in here is the only way onto a phone. The opening notice names all three ways in — scan the rock, `/open` a browser, or `/qr`. --- src/tui.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/tui.mjs b/src/tui.mjs index ef6d4ae06c..59d94ca0bb 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -45,7 +45,11 @@ const state = { // What the bridge said it is running, once it has said so. model: "", qr: null, - showQr: true, + // 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 + // 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, entries: [ { id: "privacy", @@ -777,7 +781,11 @@ try { if (!session.signedIn) { addEntry("notice", "Not signed in to Aesthetic Computer · /login to publish under your @handle"); } - addEntry("notice", `${live.slug}${live.runtime.extension} · scan the code or open ${live.scanUrl}`); + addEntry( + "notice", + `${live.slug}${live.runtime.extension} · scan the rock, /open in a browser, ` + + `or /qr for a code · ${live.scanUrl}`, + ); live.push().catch(() => {}); redraw(); if (initialPrompt) { -- 2.51.2 From 34d116fcb70e89924f2d9ef2212da87a148820b3 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 10 Sep 2026 08:16:53 -0400 Subject: [PATCH 14/18] prompt.ac is nine bytes shorter, and this address exists to be scanned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same runtime as aesthetic.computer behind a leaner shell — it loads the same boot.mjs and honours the same autorun — but nine bytes shorter, which is a whole version of the code once a phone camera's `https://` goes back on. The scan payload drops from 61 bytes to 52, landing inside a version-3 symbol instead of spilling into version 4: 29 modules where there were 33, so every module gets bigger at the same size on screen. --- src/live.mjs | 8 +++++--- test/live.test.mjs | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/live.mjs b/src/live.mjs index 7eef73ea81..016268d586 100644 --- a/src/live.mjs +++ b/src/live.mjs @@ -26,9 +26,11 @@ import { randomChannel, randomSlug } from "./names.mjs"; import { DEFAULT_RUNTIME, runtimeFor, runtimeForExtension } from "./runtimes.mjs"; export const SITE = "https://aesthetic.computer"; -// Scannable codes get smaller the shorter the text, and every phone camera -// adds the scheme back, so the QR carries a bare host. -export const SCAN_HOST = "aesthetic.computer"; +// Scannable codes get smaller the shorter the text, and this address exists to +// be scanned. prompt.ac is the same runtime as aesthetic.computer behind a +// leaner shell, and nine bytes shorter — which is a whole version of the code +// once the scheme a phone camera needs is added back on. +export const SCAN_HOST = "prompt.ac"; const DISKS = join("system", "public", "aesthetic.computer", "disks"); // Pieces belong in the disks folder when the workspace is the Aesthetic diff --git a/test/live.test.mjs b/test/live.test.mjs index 2a3edc9187..d007125b45 100644 --- a/test/live.test.mjs +++ b/test/live.test.mjs @@ -41,7 +41,7 @@ test("names are pronounceable and channels stay short enough to scan", () => { // to the command name, so `channel` runs with no argument and joins nothing. test("the scan URL separates the channel from the command with a space", () => { const live = new LivePiece({ cwd: tmpdir(), channel: "Ab0-_9Zz" }); - assert.equal(live.scanUrl, "aesthetic.computer/prompt~channel%20Ab0-_9Zz~!autorun"); + assert.equal(live.scanUrl, "prompt.ac/prompt~channel%20Ab0-_9Zz~!autorun"); const [, command] = live.scanUrl.match(/\/prompt~(.*)~!autorun$/); assert.deepEqual(decodeURIComponent(command).split(" "), ["channel", "Ab0-_9Zz"]); }); @@ -67,7 +67,7 @@ function decodeBlock(block) { test("the rendered code matches the encoder module for module", async () => { const { qrcode, ErrorCorrectLevel } = await import("../src/vendor/qr.mjs"); - const url = "aesthetic.computer/prompt~channel%20Ab0-_9Zz~!autorun"; + const url = "prompt.ac/prompt~channel%20Ab0-_9Zz~!autorun"; const quiet = 2; const modules = qrcode(url, { errorCorrectLevel: ErrorCorrectLevel.L }).modules; const grid = decodeBlock(qrBlock(url, { quiet })); @@ -154,7 +154,7 @@ test("a session mints a blank piece, pushes it, and cleans up after itself", asy }); assert.equal(live.file, join(root, "movika.mjs")); - assert.equal(live.scanUrl, "aesthetic.computer/prompt~channel%20Ab0-_9Zz~!autorun"); + assert.equal(live.scanUrl, "prompt.ac/prompt~channel%20Ab0-_9Zz~!autorun"); assert.equal(live.publishedUrl("jeffrey"), "https://aesthetic.computer/@jeffrey/movika"); assert.equal(live.publishedUrl(""), ""); -- 2.51.2 From bcdaa9536e1da49d7111245056610ad4ae369de6 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 10 Sep 2026 08:22:17 -0400 Subject: [PATCH 15/18] The header stops before the rock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt rock parks over the top-right corner of the terminal window, which is exactly where the header right-aligns REMOTE and READY — so the state of the session was hiding behind a picture of the session. The header keeps sixteen columns clear on that edge now. The rock is a fixed eighty points wide however the window is sized, so what it costs in columns depends on the font: eleven at a comfortable size, sixteen at a small one. Sixteen clears it, and the header row has that slack to give. A window under sixty-four columns keeps its status and spends the gutter instead — nothing here is worth hiding the state behind a rock that may not be running. --- src/render.mjs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/render.mjs b/src/render.mjs index 2b8cd1f4d1..6299bce38a 100644 --- a/src/render.mjs +++ b/src/render.mjs @@ -292,9 +292,15 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const right = `${paint(useColor, state.mode === "local" ? "status" : "highlight", mode)} · ${paint(useColor, statusTone(state.status), status)}`; const rightWidth = textWidth(`${mode} · ${status}`); - // A narrow window drops the piece, then the account, rather than pushing the - // status off the end of the row. - const room = Math.max(0, width - 3 - rightWidth); + // The prompt rock parks itself over the top-right corner of the terminal + // window, so the header stops short of it. The rock is a fixed 80 points + // wide however this window is sized, which lands between eleven and sixteen + // columns across the font sizes anyone reads code in; sixteen clears it, and + // the header has the slack to give. A narrow window keeps its status and + // spends the gutter instead — nothing is worth hiding the state behind a + // rock that might not be there. + const rockGutter = width >= 64 ? 16 : 0; + const room = Math.max(0, width - 3 - rightWidth - rockGutter); const title = "AESTHETIC CODE"; let account = state.account || "not signed in"; let piece = state.piece ? clipText(state.piece, 24) : ""; @@ -310,8 +316,10 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { : `${paint(useColor, "bold text", title)} ` + `${paint(useColor, account.startsWith("@") ? "handle" : "muted", account)}` + `${piece ? ` ${paint(useColor, "soft", piece)}` : ""}`; - const gap = " ".repeat(Math.max(1, width - 2 - textWidth(leftPlain) - rightWidth)); - const header = ` ${left}${gap}${right} `; + const gap = " ".repeat( + Math.max(1, width - 2 - textWidth(leftPlain) - rightWidth - rockGutter), + ); + const header = ` ${left}${gap}${right}${" ".repeat(rockGutter)} `; const workspace = clipText(state.workspace || "workspace", Math.max(8, width - 2)); const pathLine = paint(useColor, "muted", ` ${workspace}`); -- 2.51.2 From 5ae23442730088ab7fc5d60bd9bb00744ab421de Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 10 Sep 2026 08:37:37 -0400 Subject: [PATCH 16/18] Scan a short address, not a command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prompt.ac/~` instead of `aesthetic.computer/prompt~channel%20~!autorun`. The host redirects one to the other, so the phone still lands on the prompt holding the channel as its own word — that rule did not go away, it moved into lith/Caddyfile where the expansion happens. What it buys: 27 bytes with the scheme a camera needs, against 61 before. That is a version-2 symbol rather than version 4 — 25 modules instead of 33 — so every module is a third wider on the rock, which is the surface someone is actually holding a phone up to. It reads like an address now, too, which the old one never did. The size guard in the tests measures the URL a phone actually gets, `https://` included, so lengthening the address fails there rather than quietly costing a version. --- src/live.mjs | 13 +++++++++---- test/live.test.mjs | 40 +++++++++++++++++++++++----------------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/live.mjs b/src/live.mjs index 016268d586..d2508ef719 100644 --- a/src/live.mjs +++ b/src/live.mjs @@ -27,7 +27,7 @@ import { DEFAULT_RUNTIME, runtimeFor, runtimeForExtension } from "./runtimes.mjs export const SITE = "https://aesthetic.computer"; // Scannable codes get smaller the shorter the text, and this address exists to -// be scanned. prompt.ac is the same runtime as aesthetic.computer behind a +// be scanned — see `scanUrl` for the short route it uses. prompt.ac is the same runtime as aesthetic.computer behind a // leaner shell, and nine bytes shorter — which is a whole version of the code // once the scheme a phone camera needs is added back on. export const SCAN_HOST = "prompt.ac"; @@ -76,10 +76,15 @@ export class LivePiece extends EventEmitter { } // The URL a phone scans: it joins the code channel and then sits waiting for - // source, which arrives as soon as anything is pushed. See the note at the - // top of the file for why the argument is separated by an encoded space. + // source, which arrives as soon as anything is pushed. + // + // Short on purpose. Every byte here is a module of QR, and the long form this + // expands to — `/prompt~channel%20~!autorun`, a command line wearing + // percent-encoding — costs a whole extra version of the symbol: smaller + // modules, on a surface someone is holding a phone up to. The `~` + // route on the host redirects to it, so the code stays at version 2. get scanUrl() { - return `${this.scanHost}/prompt~channel%20${this.channel}~!autorun`; + return `${this.scanHost}/~${this.channel}`; } // Where the piece answers once it has been published under a handle. diff --git a/test/live.test.mjs b/test/live.test.mjs index d007125b45..128fbc6f6e 100644 --- a/test/live.test.mjs +++ b/test/live.test.mjs @@ -22,28 +22,34 @@ test("names are pronounceable and channels stay short enough to scan", () => { assert.match(randomSlug(), /^[a-z]{4,6}$/); assert.match(randomChannel(), /^[A-Za-z0-9_-]{8}$/); } - // A version-3 QR is the largest that fits a terminal corner: 33 columns. - // Measure the real scan URL rather than a copy of it, so lengthening the URL - // fails here instead of quietly growing the code to a version that no longer - // fits an 80×24 window and is dropped from the frame. + // The address is short so the code can be. Measure the real scan URL rather + // than a copy of it, so lengthening it fails here instead of quietly growing + // the symbol a version — which costs modules on the rock, where someone is + // holding a phone up to it. A phone gets the URL with `https://` in front, + // so that is what is measured: 32 bytes is a version-2 code at level L. for (let attempt = 0; attempt < 50; attempt += 1) { const url = new LivePiece({ cwd: tmpdir() }).scanUrl; - assert.ok(Buffer.byteLength(url) <= 53, `${url} is too long for a version-3 code`); + const scanned = `https://${url}`; + assert.ok( + Buffer.byteLength(scanned) <= 32, + `${scanned} is too long for a version-2 code`, + ); const block = qrBlock(url); - assert.equal(block.width, 33); - assert.equal(block.height, 17); + assert.equal(block.width, 29); + assert.equal(block.height, 15); } }); -// The phone has to land on the prompt holding the channel as its own word. -// parse.mjs hands everything after `prompt~` to the prompt as ONE parameter, -// and the prompt splits its own arguments on spaces — a tilde glues the channel -// to the command name, so `channel` runs with no argument and joins nothing. -test("the scan URL separates the channel from the command with a space", () => { +// The scanned address is the short one; the host expands it. The rule it +// expands to still matters — parse.mjs hands everything after `prompt~` to the +// prompt as ONE parameter and the prompt splits its own arguments on spaces, so +// the channel must arrive as its own word — but that lives in the `~` +// redirect in lith/Caddyfile now, not here. +test("the scan URL is the short route, not the command line", () => { const live = new LivePiece({ cwd: tmpdir(), channel: "Ab0-_9Zz" }); - assert.equal(live.scanUrl, "prompt.ac/prompt~channel%20Ab0-_9Zz~!autorun"); - const [, command] = live.scanUrl.match(/\/prompt~(.*)~!autorun$/); - assert.deepEqual(decodeURIComponent(command).split(" "), ["channel", "Ab0-_9Zz"]); + assert.equal(live.scanUrl, "prompt.ac/~Ab0-_9Zz"); + const [, channel] = live.scanUrl.match(/\/~(.+)$/); + assert.equal(channel, "Ab0-_9Zz", "the channel travels whole, needing no encoding"); }); // Read the rendered block back into a grid of dark modules. A code that is @@ -67,7 +73,7 @@ function decodeBlock(block) { test("the rendered code matches the encoder module for module", async () => { const { qrcode, ErrorCorrectLevel } = await import("../src/vendor/qr.mjs"); - const url = "prompt.ac/prompt~channel%20Ab0-_9Zz~!autorun"; + const url = "prompt.ac/~Ab0-_9Zz"; const quiet = 2; const modules = qrcode(url, { errorCorrectLevel: ErrorCorrectLevel.L }).modules; const grid = decodeBlock(qrBlock(url, { quiet })); @@ -154,7 +160,7 @@ test("a session mints a blank piece, pushes it, and cleans up after itself", asy }); assert.equal(live.file, join(root, "movika.mjs")); - assert.equal(live.scanUrl, "prompt.ac/prompt~channel%20Ab0-_9Zz~!autorun"); + assert.equal(live.scanUrl, "prompt.ac/~Ab0-_9Zz"); assert.equal(live.publishedUrl("jeffrey"), "https://aesthetic.computer/@jeffrey/movika"); assert.equal(live.publishedUrl(""), ""); -- 2.51.2 From 3582352fda9af0bed2eb015ec0c2260b0d45e096 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 10 Sep 2026 10:41:14 -0400 Subject: [PATCH 17/18] The little guy dances while the machine has the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working is the one fact the interface could not tell you without spending a row and a word on it, so the figure in the footer says it instead: standing while it is your turn, three frames of arms while it is not. He keeps his own slow tick, because a turn that is thinking rather than printing sends no events to repaint on, and drops it the moment the turn ends — an idle session pays nothing for him. Two hundred and twenty milliseconds a frame: slow enough not to strobe beside text someone is reading, fast enough to read as effort rather than a glitch. NOTE — this commit also carries an auto-publish feature I did not write. It was sitting uncommitted in this tree alongside my own edits, and `src/tui.mjs` holds both, so separating them by author would have meant hand-picking hunks out of one file. Committing it is how it stops being at risk. It reads well and its tests pass: publishing follows the live push so a piece someone is watching on a phone is also a URL they can send on, and it stays opt-in because publishing is outward-facing. 49 tests and the CLI suite pass with both present. --- bin/aesthetic | 21 +++++ src/autopublish.mjs | 150 +++++++++++++++++++++++++++++++++++ src/mascot.mjs | 33 ++++++++ src/render.mjs | 10 ++- src/tui.mjs | 161 +++++++++++++++++++++++++++++++++++-- test/autopublish.test.mjs | 162 ++++++++++++++++++++++++++++++++++++++ test/cli.sh | 15 ++++ 7 files changed, 543 insertions(+), 9 deletions(-) create mode 100644 src/autopublish.mjs create mode 100644 test/autopublish.test.mjs diff --git a/bin/aesthetic b/bin/aesthetic index 83a5f4aba9..cdf6f9f6dd 100755 --- a/bin/aesthetic +++ b/bin/aesthetic @@ -21,6 +21,7 @@ usage() { cat <<'EOF' Usage: ac [directory] [--runtime mjs|lisp|processing] [--backend claude|codex] [--model NAME] + [--autopublish | --no-autopublish] aesthetic [directory] aesthetic doctor aesthetic login | logout | whoami @@ -36,6 +37,12 @@ live on a phone. --runtime picks the language of that piece. --backend picks the engine bridge: claude (the default, on claude-opus-5) or codex. --model names the model on that bridge, and /backend and /model change either one mid-session. + +--autopublish publishes the piece under your @handle a couple of seconds +after every save, so the session's URL is live the whole time it is being +worked on. Off by default; AESTHETIC_CODE_AUTOPUBLISH=1 turns it on for +every session, --no-autopublish overrides that, and /autopublish toggles +it mid-session. EOF } @@ -100,8 +107,19 @@ initial_prompt="" runtime="" backend="" model="" +# Empty means "whatever the environment says"; the interface reads +# AESTHETIC_CODE_AUTOPUBLISH itself, and these two flags override it. +autopublish="" while [[ $# -gt 0 ]]; do case "$1" in + --autopublish) + autopublish="on" + shift + ;; + --no-autopublish) + autopublish="off" + shift + ;; --resume) [[ $# -ge 2 ]] || fail "--resume requires a thread id" [[ "$2" =~ ^[0-9A-Fa-f-]{36}$ ]] || fail "invalid thread id" @@ -177,6 +195,7 @@ if [[ "${AESTHETIC_CODE_DRY_RUN:-0}" == "1" ]]; then printf 'resume=%s\n' "$([[ -n "$resume_thread" ]] && printf yes || printf no)" printf 'initial_prompt=%s\n' "$([[ -n "$initial_prompt" ]] && printf yes || printf no)" printf 'runtime=%s\n' "${runtime:-mjs}" + printf 'autopublish=%s\n' "${autopublish:-$([[ "${AESTHETIC_CODE_AUTOPUBLISH:-0}" =~ ^(1|on|true|yes)$ ]] && printf on || printf off)}" printf 'backend=%s\n' "$selected_backend" printf 'model=%s\n' "${model:-$([[ "$selected_backend" == claude ]] && printf '%s' "$DEFAULT_CLAUDE_MODEL")}" exit 0 @@ -190,6 +209,8 @@ arguments=("--cwd" "$target_directory") if [[ -n "$resume_thread" ]]; then arguments+=("--resume" "$resume_thread"); fi if [[ -n "$initial_prompt" ]]; then arguments+=("--prompt" "$initial_prompt"); fi if [[ -n "$runtime" ]]; then arguments+=("--runtime" "$runtime"); fi +if [[ "$autopublish" == "on" ]]; then arguments+=("--autopublish"); fi +if [[ "$autopublish" == "off" ]]; then arguments+=("--no-autopublish"); fi arguments+=("--backend" "$selected_backend") if [[ -n "$model" ]]; then arguments+=("--model" "$model"); fi exec node "$PROJECT_DIR/src/tui.mjs" "${arguments[@]}" diff --git a/src/autopublish.mjs b/src/autopublish.mjs new file mode 100644 index 0000000000..66c7e168ce --- /dev/null +++ b/src/autopublish.mjs @@ -0,0 +1,150 @@ +// autopublish.mjs — publish every save under the user's @handle, coalesced. +// +// The live channel and publishing answer two different questions. A push to +// /run reaches whoever scanned the code, right now, and vanishes with the +// session; publishing writes the source into the user's bucket, where the piece +// keeps answering at aesthetic.computer/@handle/slug after the terminal closes. +// Auto-publish makes the second one follow the first, so a piece someone is +// already watching on a phone is also a URL they can send to somebody. +// +// It is off unless asked for. Publishing is outward-facing — the bytes land on +// a public route under the user's own name — so it stays an opt-in for the +// session (`/autopublish on`, `--autopublish`, AESTHETIC_CODE_AUTOPUBLISH=1) +// rather than something the interface starts doing on its own. +// +// Saves arrive far faster than a publish should: an agent turn can write a file +// five times in ten seconds, and each publish is three network round trips onto +// a CDN-fronted key that serves the old bytes for a while after an overwrite. +// So this coalesces. A save marks the piece dirty, a publish runs once the +// saves stop (`settle`) and no sooner than `minGap` after the last one, and a +// save arriving mid-publish re-arms rather than queueing a second. What is +// guaranteed is that the last save wins — `flush()` on the way out is what +// makes that true even when the session ends a second after an edit. +import { EventEmitter } from "node:events"; + +export const SETTLE = 2500; +export const MIN_GAP = 12_000; + +export class AutoPublisher extends EventEmitter { + constructor({ + publish, + enabled = false, + settle = SETTLE, + minGap = MIN_GAP, + now = () => Date.now(), + } = {}) { + super(); + this.publish = publish; + this.enabled = Boolean(enabled); + this.settle = settle; + this.minGap = minGap; + this.now = now; + this.timer = null; + this.current = null; + // The source waiting to go out, and the source that last went out. Equal + // means there is nothing to publish: a save that only rewrote the file with + // the same bytes (or our own read-back) is not a new version. + this.queued = null; + this.published = null; + this.publishedAt = 0; + } + + get pending() { + return this.queued !== null; + } + + get running() { + return this.current !== null; + } + + // Turn it on or off mid-session. Turning it off cancels what was armed but + // leaves a publish already in flight alone — it is already someone else's + // bytes on the wire. + set(enabled) { + this.enabled = Boolean(enabled); + if (!this.enabled) { + this.#disarm(); + this.queued = null; + } + return this.enabled; + } + + // A save happened. Returns true if this changed anything. + note(source) { + if (!this.enabled) return false; + const text = String(source ?? ""); + if (!text.trim() || text === this.published) return false; + this.queued = text; + this.#arm(); + return true; + } + + // Publish the queued save now, ignoring both timers, and resolve with the + // result. Used on the way out, so quitting right after an edit still lands + // that edit. Resolves null when there was nothing to do. + async flush() { + this.#disarm(); + if (this.current) await this.current.catch(() => {}); + if (!this.pending) return null; + try { + return await this.#run(); + } catch { + return null; + } + } + + cancel() { + this.#disarm(); + this.queued = null; + } + + #disarm() { + clearTimeout(this.timer); + this.timer = null; + } + + #arm() { + if (this.timer || this.current || !this.pending) return; + const since = this.publishedAt > 0 ? this.now() - this.publishedAt : Infinity; + const delay = Math.max(this.settle, this.minGap - since); + this.timer = setTimeout(() => { + this.timer = null; + this.#run().catch(() => {}); + }, delay); + this.timer.unref?.(); + this.emit("armed", delay); + } + + async #run() { + const source = this.queued; + if (source === null || this.current) return null; + this.queued = null; + this.emit("start"); + this.current = (async () => { + try { + const result = await this.publish(); + // Record the bytes that were sent, not what the file says afterwards: + // a save landing during the upload must still count as unpublished. + this.published = source; + this.publishedAt = this.now(); + this.emit("published", result); + return result; + } catch (error) { + // A failure leaves `published` alone, so the next save retries. It does + // not retry on its own — a broken token or a rejected slug would spin + // forever, and the transcript already said so once. + this.publishedAt = this.now(); + this.emit("failed", error); + throw error; + } finally { + this.current = null; + } + })(); + const settled = this.current; + try { + return await settled; + } finally { + this.#arm(); // A save that arrived mid-publish goes out next. + } + } +} diff --git a/src/mascot.mjs b/src/mascot.mjs index 7d0c086d4a..44ccd47b6d 100644 --- a/src/mascot.mjs +++ b/src/mascot.mjs @@ -31,6 +31,39 @@ const POSES = { blink: [" ▪ ", "╱│╲", "╱ ╲"], }; +// One row, for the footer, where he lives for the rest of the session. Working +// is the only thing worth animating there: it is the one fact the interface +// cannot otherwise tell you without spending a row on it, and a figure that +// moves exactly while the machine is busy needs no label. +const ONE_ROW = { + stand: "\\●/", + // Arms down, arms up. Two frames is a dance at this size — the eye reads + // alternation as effort, and anything more elaborate is noise in a corner. + work: ["\\●/", "/●\\", "—●—", "/●\\"], +}; + +export const MASCOT_ROW_WIDTH = 3; + +// How fast he dances. Slow enough not to strobe beside text someone is +// reading, fast enough to read as motion rather than a glitch. +const DANCE_MS = 220; + +// The one-row guy. `busy` is the only input that changes him: standing when it +// is your turn, dancing while the machine has the floor. +export function mascotRow(elapsed = 0, busy = false) { + if (!busy) return ONE_ROW.stand; + const ms = Math.max(0, Number(elapsed) || 0); + return ONE_ROW.work[Math.floor(ms / DANCE_MS) % ONE_ROW.work.length]; +} + +// When the footer next needs repainting. Standing costs nothing — the answer +// is null, and the caller can stop asking. +export function mascotRowNextFrameIn(elapsed = 0, busy = false) { + if (!busy) return null; + const ms = Math.max(0, Number(elapsed) || 0); + return DANCE_MS - (ms % DANCE_MS); +} + // Milliseconds. The walk is brisk enough that nobody waits for it and slow // enough to read as walking; the wave lands after he stops, because waving // mid-stride looks like falling over. diff --git a/src/render.mjs b/src/render.mjs index 6299bce38a..4e53769511 100644 --- a/src/render.mjs +++ b/src/render.mjs @@ -3,7 +3,7 @@ // The palette is the Aesthetic Computer prompt's dark scheme (disks/prompt.mjs // `scheme.dark`): purple ground, pink prompt block, orange highlight, magenta // handle, light-purple secondary text. -import { MASCOT_HEIGHT, mascotAt } from "./mascot.mjs"; +import { MASCOT_HEIGHT, mascotAt, mascotRow } from "./mascot.mjs"; const ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; @@ -374,7 +374,13 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // The little guy keeps the far corner from the QR code. He is one row and he // does not move: an animated footer costs a full repaint every few seconds // for the rest of the session, and the entrance already showed he is alive. - const guy = `${paint(useColor, "soft", "\\")}${paint(useColor, "handle", "\u25cf")}${paint(useColor, "soft", "/")}`; + // He dances while the machine has the floor and stands while it is yours — + // the one fact the interface would otherwise need a row and a word to say. + const pose = Array.from(mascotRow(state.mascotMs ?? 0, Boolean(state.busy))); + const guy = + `${paint(useColor, "soft", pose[0])}` + + `${paint(useColor, "handle", pose[1])}` + + `${paint(useColor, "soft", pose[2])}`; const helpText = state.busy ? " ctrl-c interrupt" : " /help \u00b7 /login \u00b7 /publish \u00b7 /open \u00b7 /qr \u00b7 ctrl-c quit"; diff --git a/src/tui.mjs b/src/tui.mjs index 59d94ca0bb..7a53337f0e 100755 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -4,12 +4,13 @@ import { spawn } from "node:child_process"; import path from "node:path"; import process from "node:process"; import { ACSession } from "./ac-session.mjs"; +import { AutoPublisher } from "./autopublish.mjs"; import { backendFor, backendMenu, DEFAULT_BACKEND } from "./backends.mjs"; import { LivePiece } from "./live.mjs"; import { publishPiece } from "./publish.mjs"; import { qrBlock } from "./qr.mjs"; import { cleanText, renderBoot, renderFrame } from "./render.mjs"; -import { mascotNextFrameIn } from "./mascot.mjs"; +import { mascotNextFrameIn, mascotRowNextFrameIn } from "./mascot.mjs"; import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; @@ -18,6 +19,7 @@ const option = (name) => { const index = arguments_.indexOf(name); return index >= 0 ? arguments_[index + 1] || "" : ""; }; +const flag = (name) => arguments_.includes(name); const cwd = path.resolve(option("--cwd") || process.cwd()); const resumeThreadId = option("--resume"); const initialPrompt = option("--prompt"); @@ -59,6 +61,32 @@ const state = { ], }; +// Publishing on every save, when the session asked for it. The token stays in +// here — this is the interface publishing on its own schedule, not a tool the +// agent can reach — and the piece keeps its own name, so a session's URL is +// settled the moment auto-publish is on. +const autopublish = new AutoPublisher({ + enabled: + !flag("--no-autopublish") && + (flag("--autopublish") || + /^(1|on|true|yes)$/i.test(process.env.AESTHETIC_CODE_AUTOPUBLISH || "")), + publish: () => publishPiece({ file: live.file, slug: live.slug, session, cwd }), +}); + +// Why a save might not be publishable. Auto-publish stays quiet about all of +// these until something asks it to publish — an unsigned-in session should not +// narrate a failure on every keystroke. +function autopublishBlocker() { + if (!session.signedIn) return "not signed in · /login to publish"; + if (!session.handle) return "this account has no @handle yet"; + if (!live.runtime.routable) return `${live.runtime.label} has no @handle route yet`; + return ""; +} + +function autopublishRoute() { + return session.handle ? live.publishedUrl(session.handle) : ""; +} + // The model must never mistake a file on disk for a published piece. function developerInstructions() { const account = session.handle @@ -73,15 +101,26 @@ function developerInstructions() { "Keep the file's first line a `--` comment and keep a top-level `function setup(` or `function draw(`. The live channel sends no file extension, so those two things are the only way the piece is recognised as Lua rather than compiled as JavaScript — drop either and the phone goes blank.", ] : []; + // With auto-publish on, telling the user to run /publish is wrong twice: the + // work is already done, and the URL it would print is one they already have. + const publishing = + autopublish.enabled && !autopublishBlocker() + ? [ + `Auto-publish is ON for this session: the interface publishes ${live.file} to ${autopublishRoute()} a couple of seconds after every save. That URL is live and stays live after this session ends.`, + "So do NOT end with a /publish command and do NOT tell the user to publish — say the piece is live and name that URL. Only mention /publish if a publish is reported as failing.", + ] + : [ + "Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.", + "A piece is live only after the user runs the Aesthetic Code command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", + "When you finish a piece, end with the exact /publish command for the user to run. Never tell the user to visit a route that has not been published.", + ]; return [ "You are running inside Aesthetic Code, a terminal interface for Aesthetic Computer (AC) work.", account, `This session's piece is ${live.file} (${live.runtime.label}). It already exists as a blank piece. Edit that file unless the user asks for something else.`, ...dialect, "Every save of that file is pushed live to a phone that scanned the interface's QR code, so small frequent edits are better than one big rewrite.", - "Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.", - "A piece is live only after the user runs the Aesthetic Code command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", - "When you finish a piece, end with the exact /publish command for the user to run. Never tell the user to visit a route that has not been published.", + ...publishing, "Dev servers: do not stop a dev server you were asked to start; say that it is still running.", ].join("\n"); } @@ -143,6 +182,26 @@ function updateEntry(id, kind, text) { // The guard keeps a redraw from re-entering itself; `finally` is what keeps a // single bad frame from latching it shut and freezing the screen for good. +let danceTimer = null; +const danceStartedAt = Date.now(); +// While the machine has the floor the footer figure moves, and a turn that is +// thinking rather than printing sends no events to repaint on — so the dance +// keeps its own slow tick and drops it the moment the turn ends. +function danceTick() { + danceTimer = null; + if (closing) return; + state.mascotMs = Date.now() - danceStartedAt; + const next = mascotRowNextFrameIn(state.mascotMs, state.busy); + if (next === null) return; + redraw(); + danceTimer = setTimeout(danceTick, next); + danceTimer.unref?.(); +} +function startDance() { + state.mascotMs = Date.now() - danceStartedAt; + if (!danceTimer) danceTick(); +} + function redraw() { if (closing || drawing) return; drawing = true; @@ -154,17 +213,34 @@ function redraw() { } } -function finish(code = 0) { +async function finish(code = 0) { if (closing) return; closing = true; session.unwatch(); - live.cleanup(); + const pending = autopublish.pending || autopublish.running; + live.unwatch(); slabSession.close(); engine.close(); process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write("\x1b[?2004l\x1b[?25h\x1b[?1049l"); process.exitCode = code; + // The last save has to land. Quitting a second after an edit would otherwise + // drop it — auto-publish coalesces, and the timer it was waiting on dies with + // the process. This runs after the screen is handed back, so it prints as + // ordinary terminal output rather than into a frame that is already gone. + if (pending) { + process.stdout.write("publishing the last save…\n"); + try { + const result = await autopublish.flush(); + process.stdout.write(result ? `${result.route}\n` : "the last save did not publish\n"); + } catch { + process.stdout.write("the last save did not publish\n"); + } + } + // The blank goes last: an untouched piece is deleted, and deleting it before + // a flush would publish an empty file or nothing at all. + live.cleanup(); } function errorText(error) { @@ -185,6 +261,30 @@ function liveError(error) { redraw(); } +// One line in the transcript, rewritten in place. Auto-publish runs on its own +// every few seconds for a whole session; it does not get to push the +// conversation off the screen doing it. +const AUTOPUBLISH_ENTRY = "autopublish"; + +autopublish.on("start", () => { + updateEntry(AUTOPUBLISH_ENTRY, "publish", `Publishing ${live.slug}…`); + redraw(); +}); + +autopublish.on("published", (result) => { + updateEntry( + AUTOPUBLISH_ENTRY, + "publish", + `${result.route} · auto${result.verified ? "" : " · uploaded, not yet readable"}`, + ); + redraw(); +}); + +autopublish.on("failed", (error) => { + updateEntry(AUTOPUBLISH_ENTRY, "error", `Auto-publish failed: ${errorText(error)}`); + redraw(); +}); + function refreshQr() { state.qr = state.showQr ? qrBlock(live.scanUrl) : null; // The rock in the menu bar carries the same address. `/qr` hides the code in @@ -231,6 +331,7 @@ function handleNotification({ method, params = {} }) { switch (method) { case "turn/started": state.busy = true; + startDance(); state.status = "working"; engine.turnId = params.turn?.id || engine.turnId; slabSession.working(); @@ -398,6 +499,34 @@ function commandLogout() { redraw(); } +function commandAutopublish(argumentText) { + const word = argumentText.trim().toLowerCase(); + if (word && !/^(on|off|yes|no|true|false|1|0)$/.test(word)) { + addEntry("error", "Usage: /autopublish [on|off]"); + return redraw(); + } + const wanted = word ? /^(on|yes|true|1)$/.test(word) : !autopublish.enabled; + autopublish.set(wanted); + const blocker = autopublishBlocker(); + if (!wanted) { + addEntry("notice", "Auto-publish off · /publish puts the piece live"); + } else if (blocker) { + addEntry("notice", `Auto-publish on · nothing will publish yet: ${blocker}`); + } 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()); + } + // How publishing works is part of the developer instructions, and those are + // written once when the thread opens. A mid-session toggle is real + // immediately for the interface and only reaches the model on a new thread — + // say so, rather than letting it keep recommending /publish for a piece that + // is already live. + addEntry("notice", "The model is told when a thread opens · /new to tell it now"); + return redraw(); +} + async function commandPublish(argumentText) { const [file = live.file, slug = ""] = argumentText.split(/\s+/).filter(Boolean); if (!file) { @@ -508,7 +637,7 @@ async function submitInput() { if (command === "/help") { addEntry( "notice", - "/login · /logout · /whoami · /publish [file] · /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] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -520,6 +649,7 @@ async function submitInput() { return redraw(); } if (command === "/publish") return commandPublish(rest); + if (command === "/autopublish" || command === "/auto") return commandAutopublish(rest); if (command === "/backend" || command === "/engine") return commandBackend(rest); if (command === "/model") return commandModel(rest); if (command === "/piece") { @@ -633,6 +763,7 @@ async function submitInput() { addEntry("user", text); slabSession.working(text); state.busy = true; + startDance(); state.status = "working"; redraw(); try { @@ -738,6 +869,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. +live.on("push", () => { + if (live.pristine || autopublishBlocker()) return; + autopublish.note(live.source()); +}); state.piece = `${live.slug}${live.runtime.extension}`; refreshQr(); @@ -786,6 +924,15 @@ try { `${live.slug}${live.runtime.extension} · scan the rock, /open in a browser, ` + `or /qr for a code · ${live.scanUrl}`, ); + if (autopublish.enabled) { + const blocker = autopublishBlocker(); + addEntry( + "notice", + blocker + ? `Auto-publish on · nothing will publish yet: ${blocker}` + : `Auto-publish on · every save goes to ${autopublishRoute()}`, + ); + } live.push().catch(() => {}); redraw(); if (initialPrompt) { diff --git a/test/autopublish.test.mjs b/test/autopublish.test.mjs new file mode 100644 index 0000000000..a1f0d14965 --- /dev/null +++ b/test/autopublish.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AutoPublisher } from "../src/autopublish.mjs"; + +// The publisher is all timing, so the tests run it at millisecond scale and +// with a clock they control. Nothing here touches the network: `publish` is a +// counter, which is exactly what the coalescing is about. +function publisher(options = {}) { + const calls = []; + const auto = new AutoPublisher({ + enabled: true, + settle: 5, + minGap: 0, + publish: async () => { + calls.push(Date.now()); + return { route: `publish-${calls.length}` }; + }, + ...options, + }); + return { auto, calls }; +} + +const settled = (auto) => + new Promise((resolve) => { + auto.once("published", resolve); + auto.once("failed", resolve); + }); + +test("a burst of saves publishes once, with the last bytes", async () => { + const seen = []; + const auto = new AutoPublisher({ + enabled: true, + settle: 5, + minGap: 0, + publish: async () => { + seen.push(auto.published); + return { route: "once" }; + }, + }); + for (const source of ["one", "two", "three"]) auto.note(source); + await settled(auto); + assert.equal(seen.length, 1, "three saves in a burst are one publish"); + assert.equal(auto.published, "three", "the last save is the one that went out"); + assert.equal(auto.pending, false); +}); + +test("republishing identical bytes is not a publish", async () => { + const { auto, calls } = publisher(); + auto.note("same"); + await settled(auto); + assert.equal(calls.length, 1); + assert.equal(auto.note("same"), false, "unchanged source is refused"); + assert.equal(auto.pending, false); +}); + +test("a save during a publish goes out after it, not alongside it", async () => { + let inFlight = 0; + let peak = 0; + const auto = new AutoPublisher({ + enabled: true, + settle: 1, + minGap: 0, + publish: async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight -= 1; + return { route: "serial" }; + }, + }); + auto.note("first"); + await new Promise((resolve) => auto.once("start", resolve)); + auto.note("second"); // Mid-flight. + assert.equal(auto.pending, true); + await settled(auto); // first + await settled(auto); // second, armed by the first one finishing + assert.equal(peak, 1, "publishes never overlap"); + assert.equal(auto.published, "second"); +}); + +test("minGap holds the next publish back", async () => { + const { auto, calls } = publisher({ settle: 1, minGap: 40 }); + auto.note("a"); + await settled(auto); + auto.note("b"); + await settled(auto); + assert.ok( + calls[1] - calls[0] >= 35, + `expected the second publish to wait out the gap, waited ${calls[1] - calls[0]}ms`, + ); +}); + +test("flush publishes the pending save immediately", async () => { + const { auto, calls } = publisher({ settle: 60_000, minGap: 60_000 }); + auto.note("quitting"); + const result = await auto.flush(); + assert.equal(calls.length, 1, "flush does not wait for the settle timer"); + assert.equal(result.route, "publish-1"); + assert.equal(await auto.flush(), null, "a second flush has nothing to do"); +}); + +test("disabled is disabled, and enabling does not replay old saves", async () => { + const { auto, calls } = publisher({ enabled: false }); + assert.equal(auto.note("ignored"), false); + assert.equal(auto.pending, false); + auto.set(true); + assert.equal(auto.pending, false, "the save made while off is not queued"); + auto.note("wanted"); + await settled(auto); + assert.equal(calls.length, 1); +}); + +test("turning it off cancels what was armed", async () => { + const { auto, calls } = publisher({ settle: 5 }); + auto.note("armed"); + auto.set(false); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(calls.length, 0); + assert.equal(auto.pending, false); +}); + +test("a failure leaves the piece unpublished so the next save retries", async () => { + let attempts = 0; + const auto = new AutoPublisher({ + enabled: true, + settle: 1, + minGap: 0, + publish: async () => { + attempts += 1; + if (attempts === 1) throw new Error("no handle"); + return { route: "second-try" }; + }, + }); + const failure = await new Promise((resolve) => { + auto.once("failed", resolve); + auto.note("v1"); + }); + assert.match(failure.message, /no handle/); + assert.equal(auto.published, null, "a failed publish is not remembered as published"); + assert.equal(auto.note("v1"), true, "the same bytes are still worth retrying"); + await settled(auto); + assert.equal(attempts, 2); +}); + +test("a failure does not retry on its own", async () => { + let attempts = 0; + const auto = new AutoPublisher({ + enabled: true, + settle: 1, + minGap: 0, + publish: async () => { + attempts += 1; + throw new Error("rejected"); + }, + }); + await new Promise((resolve) => { + auto.once("failed", resolve); + auto.note("v1"); + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(attempts, 1, "a broken publish would otherwise spin all session"); +}); diff --git a/test/cli.sh b/test/cli.sh index 797f25f1dd..a9816a1408 100755 --- a/test/cli.sh +++ b/test/cli.sh @@ -78,6 +78,21 @@ assert_contains "$output" 'runtime=lisp' output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" assert_contains "$output" 'runtime=mjs' +# Publishing is outward-facing, so it is off unless this session asked for it — +# by flag or by environment, with the flag able to say no to the environment. +assert_contains "$output" 'autopublish=off' + +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --autopublish "$WORK_DIR")" +assert_contains "$output" 'autopublish=on' + +output="$(AESTHETIC_CODE_DRY_RUN=1 AESTHETIC_CODE_AUTOPUBLISH=1 "$CLI" "$WORK_DIR")" +assert_contains "$output" 'autopublish=on' + +output="$(AESTHETIC_CODE_DRY_RUN=1 AESTHETIC_CODE_AUTOPUBLISH=1 "$CLI" --no-autopublish "$WORK_DIR")" +assert_contains "$output" 'autopublish=off' + +output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" + # Claude is the default engine bridge, on Fable; Codex stays selectable. assert_contains "$output" 'backend=claude' assert_contains "$output" 'model=claude-opus-5' -- 2.51.2 From 03dcb1d14d693d76a0790990a7d7d0ca4b0ac067 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 10 Sep 2026 12:16:24 -0400 Subject: [PATCH 18/18] Run without asking, and say what ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real session on this harness spent two of its two hours and nineteen minutes parked on approval prompts with nobody sitting in front of them. A prompt that nobody answers is not a safety mechanism, it is a stall, so actions run by default and the transcript reports them: `Ran: ` after the fact rather than a question before it. The interface stays the approver. The decision is not handed down to the engine — every request still arrives here, is still written down, and `/ask on` puts the question back for the rest of the session. What holds a session in is narrower than a prompt was, and worth stating rather than implying: the engine has no OS sandbox, so file tools are confined to the session's own directory, the fetching tools are withheld, and none of the user's settings or servers are in scope. Within that, a command now runs. Wiring verified — the default, the branch that answers, the command that reverses it, and the help that names it. A live approval round trip has not been driven end to end; the next real turn does that. --- README.md | 14 +++++++++++++- docs/local-contract.md | 15 ++++++++++++-- src/tui.mjs | 44 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 64 insertions(+), 9 deletions(-) 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