From 2ccbe818112337fefcab226717f5e8123b0b77ac Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 09:32:02 -0700 Subject: [PATCH] =?UTF-8?q?native:=20Aesel=20as=20a=20piece=20=E2=80=94=20?= =?UTF-8?q?Claude=20and=20hosted=20engines,=20tab=20runs=20the=20piece;=20?= =?UTF-8?q?new=20flashes=20boot=20into=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pieces/aesel.mjs draws the desktop Aesel session on the framebuffer: the transcript, a pink prompt, approvals answered with y/a/n, and the piece the agent writes at /pieces/.mjs, which tab runs on this machine. Escape leaves the conversation running; `aesel` reattaches. Two engines, one interface. lib/aesel-bridge.mjs is Easel's claude-server.mjs with the process removed: it translates Claude Code's headless stream-json over system.pty2. lib/aesel-ac.mjs is ac-server.mjs without Node: the hosted engine at /api/easel-inference, the write_piece loop on the device, the server-sent-event stream read once a frame. Both are pure and covered by node --test. Runtime: a raw line mode for the PTY (pty_spawn_ex — cfmakeraw on the slave, whole lines out via system.pty2.lines, pty_write loops so a long JSON line lands intact) and an { out, timeout } option on fetchPost that streams curl unbuffered into a caller-owned file kept on completion, since the 8 KB result slot would truncate a stream. flash-mac.sh's default boot piece moves from notepat to aesel; devices already flashed keep the piece their /mnt/config.json names. --- fedac/native/SCORE.md | 52 +++ fedac/native/lib/aesel-ac.mjs | 297 +++++++++++++ fedac/native/lib/aesel-ac.test.mjs | 209 +++++++++ fedac/native/lib/aesel-bridge.mjs | 417 +++++++++++++++++ fedac/native/lib/aesel-bridge.test.mjs | 155 +++++++ fedac/native/pieces/aesel.mjs | 567 ++++++++++++++++++++++++ fedac/native/pieces/prompt.mjs | 1 + fedac/native/scripts/build-and-flash.sh | 3 + fedac/native/scripts/flash-mac.sh | 11 +- fedac/native/src/js-bindings.c | 124 +++++- fedac/native/src/js-bindings.h | 1 + fedac/native/src/pty.c | 79 +++- fedac/native/src/pty.h | 23 + 13 files changed, 1923 insertions(+), 16 deletions(-) create mode 100644 fedac/native/lib/aesel-ac.mjs create mode 100644 fedac/native/lib/aesel-ac.test.mjs create mode 100644 fedac/native/lib/aesel-bridge.mjs create mode 100644 fedac/native/lib/aesel-bridge.test.mjs create mode 100644 fedac/native/pieces/aesel.mjs diff --git a/fedac/native/SCORE.md b/fedac/native/SCORE.md index 9c49e8c57d..db46edead6 100644 --- a/fedac/native/SCORE.md +++ b/fedac/native/SCORE.md @@ -212,6 +212,58 @@ Critical fixes applied to the OTA flash path: See [internals.md](internals.md) for the full boot sequence and system architecture narrative. +## Aesel On Native + +`pieces/aesel.mjs` is the desktop Aesel drawn as a piece: a conversation with +Claude on the left of the keyboard, the piece it is writing in `/pieces`, and +one key (tab) that runs that piece on this machine. New flashes boot into it +(`BOOT_PIECE` default in `scripts/flash-mac.sh`); a device already flashed +keeps the piece its `/mnt/config.json` names — switch one over ssh with +`sed -i 's/"piece":"[a-z-]*"/"piece":"aesel"/' /mnt/config.json`, or type +`aesel` at the prompt. + +How it is built: + +- **Bridge**: `/bin/claude --print --input-format stream-json --output-format + stream-json`, the same headless protocol Easel's `claude-server.mjs` speaks, + with the same two load-bearing flags (`--permission-prompt-tool stdio`, so + approvals come to the piece; `--setting-sources ""`, so the account's + allow-lists and MCP servers stay out). `lib/aesel-bridge.mjs` is the + translator, pure and covered by `node --test lib/aesel-bridge.test.mjs`. +- **Transport**: `system.pty2.spawn(cmd, args, cols, rows, { raw: true, cwd })`. + Raw mode (`pty_spawn_ex` in `src/pty.c`) puts the slave in `cfmakeraw` so + nothing the piece writes echoes back and no input line is cut at the + canonical 4095-byte limit; output bypasses the terminal grid and arrives as + `system.pty2.lines` (whole lines, drained once per paint) with + `system.pty2.overflow` when the 256 KB buffer dropped bytes. `pty_write` + now loops until a whole line is delivered. +- **Workspace**: the child runs in `/pieces` with the session's blank piece at + `/pieces/.mjs`, and `--append-system-prompt` describes the native + piece API (see `instructionsFor` in the lib). Every write is gated by the + approval bar (`y` once, `a` for the session, `n` deny). +- **Persistence**: the child outlives the piece. Escape goes to the prompt, + tab runs the authored piece, and `aesel` reattaches to the same + conversation. While Aesel is not painting, lines queue in the raw buffer; + once it is full the child blocks on stdout until Aesel returns. `/quit` + ends the session. + +- **Hosted engine** (`/backend ac`, or boot with `aesel:ac[:glm|qwen|deepseek]`): + Easel's `ac-server.mjs` ported in `lib/aesel-ac.mjs` (`node --test + lib/aesel-ac.test.mjs`). POSTs the conversation to + `/api/easel-inference` with the AC token the `link` pairing wrote into + `/mnt/config.json`, runs the one-tool agent loop (`write_piece`) on the + device, and reads the server-sent-event stream frame by frame through + `system.fetchPost(url, body, headers, { out, timeout })` — the new `out` + option streams curl unbuffered into a caller-owned file that is kept on + completion (the 8 KB `fetchResult` slot would truncate it) and carries the + server's error body on an HTTP refusal. No approvals: the tool only writes + the session's own piece. A device flashed without linking has no token and + the bridge says so. + +Not carried over from desktop Aesel yet: the `/run` live push and QR, the +Codex bridge, and the piece preview beside the transcript (the piece runs +full-screen instead). + ## Operational Checks - USB logs must be checked on every release candidate: diff --git a/fedac/native/lib/aesel-ac.mjs b/fedac/native/lib/aesel-ac.mjs new file mode 100644 index 0000000000..01da11d130 --- /dev/null +++ b/fedac/native/lib/aesel-ac.mjs @@ -0,0 +1,297 @@ +// aesel-ac.mjs — the Aesthetic Computer engine bridge, for a piece. +// +// easel/src/ac-server.mjs without Node. The hosted engine needs no vendor +// binary: the piece POSTs to aesthetic.computer, which buys the inference and +// meters it against the device's @handle, and the reply is a server-sent-event +// stream in Anthropic's shape. Nothing runs the agent loop for us, so the loop +// lives here: send the conversation, read the stream, run the one tool the +// model may call, append the result, go round again until it stops asking. +// +// The transport is injected because the runtime's HTTP is not fetch(): it is +// curl started by system.fetchPost({ out }) and a file the piece re-reads once +// a frame. So the bridge is driven by tick(), not by promises — the piece calls +// tick() from paint, the bridge polls the transport, parses whatever new lines +// landed, and emits the same events the Claude bridge does. +// +// One tool, on purpose. The session exists to produce the next version of one +// piece; write_piece is that, and a wider file API would be a larger surface to +// secure and a larger prompt to pay for on a metered tier. + +import { uuid as defaultUuid } from "./aesel-bridge.mjs"; + +export const SITE = "https://aesthetic.computer"; +export const DEFAULT_AC_MODEL = "z-ai/glm-4.6"; + +// Names a person would type, mapped to what the endpoint allowlists. The +// server decides in the end; these exist so `/model glm` works. +export const AC_MODELS = { + glm: "z-ai/glm-4.6", + qwen: "qwen/qwen3-coder", + deepseek: "deepseek/deepseek-chat-v3.1", +}; + +export function resolveAcModel(name) { + return AC_MODELS[name] || name || DEFAULT_AC_MODEL; +} + +// The short name for the header: "glm" rather than "z-ai/glm-4.6". +export function acModelLabel(model) { + for (const [label, id] of Object.entries(AC_MODELS)) if (id === model) return label; + return model.split("/").pop(); +} + +export const WRITE_PIECE = { + name: "write_piece", + description: + "Write the complete new source of the session's piece. Always send the whole file, never a patch or a fragment — what you send replaces the file exactly. The person can run it on this machine as soon as it is saved, so prefer several small writes over one large one.", + input_schema: { + type: "object", + properties: { + source: { type: "string", description: "The entire contents of the piece file." }, + note: { type: "string", description: "One short line on what changed, for the person watching." }, + }, + required: ["source"], + }, +}; + +const MAX_ROUNDS = 12; +const MAX_TOKENS = 8192; + +// Parse the complete `data:` lines of a server-sent-event stream from +// `offset` on. Returns the parsed payloads and where the next parse starts; +// a partial last line waits for more bytes. +export function parseSse(text, offset = 0) { + const events = []; + let cut = text.indexOf("\n", offset); + while (cut !== -1) { + const line = text.slice(offset, cut).trim(); + offset = cut + 1; + cut = text.indexOf("\n", offset); + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6); + if (payload === "[DONE]") continue; + try { + events.push(JSON.parse(payload)); + } catch (_) { + // A torn or foreign line is not ours to fail on. + } + } + return { events, offset }; +} + +// The server's refusal, if the error body carries one. +export function errorMessage(body, fallback) { + try { + const parsed = JSON.parse(String(body || "").trim()); + if (parsed?.error?.message) return parsed.error.message; + } catch (_) {} + return fallback; +} + +export class AcBridge { + constructor({ + transport, // { start(bodyJson, headers) → bool, poll() → { text, done, error }, cancel() } + token = "", + model = DEFAULT_AC_MODEL, + instructions = "", + file = "", + readPiece = () => "", + writePiece = () => false, + uuid = defaultUuid, + }) { + this.transport = transport; + this.token = token; + this.model = resolveAcModel(model); + this.instructions = instructions; + this.file = file; + this.readPiece = readPiece; + this.writePiece = writePiece; + this.mint = uuid; + this.sessionId = ""; + this.events = []; + this.messages = []; + this.turns = 0; + this.turnId = null; + this.rounds = 0; + this.round = null; + this.ready = false; + } + + // Same shape as the Claude bridge, so the piece can hold either. There is + // no child to spawn; the arguments are empty. + launch(resume = "") { + this.sessionId = resume || this.mint(); + this.messages = []; + this.turnId = null; + this.round = null; + this.ready = false; + return []; + } + + handshake() { + if (!this.token) { + this.#emit({ type: "error", fatal: true, message: "the aesthetic engine needs this device linked to a handle: type `link` at the prompt" }); + return; + } + this.ready = true; + this.#emit({ type: "ready", model: this.model, session: this.sessionId }); + } + + get busy() { + return this.turnId !== null; + } + + // No approvals: the one tool writes the session's own piece. + get pendingApproval() { + return null; + } + + approve() { + return false; + } + + feed() {} + + take() { + const out = this.events; + this.events = []; + return out; + } + + say(text) { + this.turnId = `turn-${++this.turns}`; + this.rounds = 0; + this.messages.push({ role: "user", content: text }); + this.#emit({ type: "turn", status: "started", id: this.turnId }); + this.#startRound(); + } + + interrupt() { + if (!this.turnId) return false; + this.transport.cancel(); + this.round = null; + const id = this.turnId; + this.turnId = null; + this.#emit({ type: "turn", status: "interrupted", id }); + return true; + } + + // Called once a frame while a turn is in flight. + tick() { + if (!this.round) return; + const { text = "", done = false, error = "" } = this.transport.poll() || {}; + const round = this.round; + const parsed = parseSse(text, round.offset); + round.offset = parsed.offset; + for (const event of parsed.events) this.#event(round, event); + if (round.failed) return; // the turn already ended inside #event + if (!done) return; + if (error) { + this.#fail(errorMessage(text, error)); + return; + } + this.#finishRound(round); + } + + #startRound() { + const source = this.readPiece() || ""; + const system = [{ type: "text", text: this.instructions }]; + if (source) system.push({ type: "text", text: `Current source of ${this.file}:\n\n${source}` }); + const body = { + model: this.model, + system, + messages: this.messages, + tools: [WRITE_PIECE], + max_tokens: MAX_TOKENS, + }; + this.round = { offset: 0, text: "", blocks: [], partials: new Map(), stop: "end_turn", failed: false }; + const ok = this.transport.start(JSON.stringify(body), { Authorization: `Bearer ${this.token}` }); + if (!ok) this.#fail("another request is already in flight on this device; try again in a moment"); + } + + #event(round, event) { + if (event.type === "content_block_start") { + const block = event.content_block; + if (block?.type === "tool_use") round.partials.set(event.index, { id: block.id, name: block.name, json: "" }); + } else if (event.type === "content_block_delta") { + const delta = event.delta; + if (delta?.type === "text_delta" && delta.text) { + round.text += delta.text; + this.#emit({ type: "delta", text: delta.text }); + } else if (delta?.type === "input_json_delta") { + const partial = round.partials.get(event.index); + if (partial) partial.json += delta.partial_json || ""; + } + } else if (event.type === "content_block_stop") { + const partial = round.partials.get(event.index); + if (partial) { + let input = {}; + try { input = JSON.parse(partial.json || "{}"); } catch (_) {} + round.blocks.push({ type: "tool_use", id: partial.id, name: partial.name, input }); + round.partials.delete(event.index); + } + } else if (event.type === "message_delta") { + if (event.delta?.stop_reason) round.stop = event.delta.stop_reason; + } else if (event.type === "error") { + round.failed = true; + this.#fail(event.error?.message || "inference error"); + } + } + + #finishRound(round) { + this.round = null; + const assistant = []; + if (round.text) assistant.push({ type: "text", text: round.text }); + for (const block of round.blocks) assistant.push(block); + if (assistant.length) this.messages.push({ role: "assistant", content: assistant }); + + if (round.stop !== "tool_use" || !round.blocks.length) { + const id = this.turnId; + this.turnId = null; + this.#emit({ type: "turn", status: "completed", id }); + return; + } + + const results = round.blocks.map((block) => this.#runTool(block)); + this.messages.push({ role: "user", content: results }); + // Bounded: a model that loops is a model spending someone's daily budget on a loop. + if (++this.rounds >= MAX_ROUNDS) { + this.#fail(`stopped after ${MAX_ROUNDS} tool rounds`); + return; + } + this.#startRound(); + } + + #runTool(block) { + const note = String(block.input?.note || "").trim(); + const label = note ? `${this.file} · ${note}` : this.file; + if (block.name !== "write_piece") { + this.#emit({ type: "tool", status: "failed", id: block.id, kind: "call", label: block.name, path: "" }); + return { type: "tool_result", tool_use_id: block.id, is_error: true, content: `No tool named ${block.name}. The only tool is write_piece.` }; + } + const source = block.input?.source; + if (typeof source !== "string" || !source.trim()) { + this.#emit({ type: "tool", status: "failed", id: block.id, kind: "file", label, path: this.file }); + return { type: "tool_result", tool_use_id: block.id, is_error: true, content: "write_piece needs the complete source of the file." }; + } + this.#emit({ type: "tool", status: "started", id: block.id, kind: "file", label, path: this.file }); + const ok = this.writePiece(source.endsWith("\n") ? source : `${source}\n`); + if (!ok) { + this.#emit({ type: "tool", status: "failed", id: block.id, kind: "file", label, path: this.file }); + return { type: "tool_result", tool_use_id: block.id, is_error: true, content: `Could not write ${this.file}.` }; + } + this.#emit({ type: "tool", status: "completed", id: block.id, kind: "file", label, path: this.file }); + return { type: "tool_result", tool_use_id: block.id, content: "Saved. The person can run it on this machine now." }; + } + + #fail(message) { + this.round = null; + const id = this.turnId; + this.turnId = null; + this.#emit({ type: "turn", status: "failed", id, error: message }); + } + + #emit(event) { + this.events.push(event); + } +} diff --git a/fedac/native/lib/aesel-ac.test.mjs b/fedac/native/lib/aesel-ac.test.mjs new file mode 100644 index 0000000000..94035673c4 --- /dev/null +++ b/fedac/native/lib/aesel-ac.test.mjs @@ -0,0 +1,209 @@ +// node --test fedac/native/lib/aesel-ac.test.mjs +// +// Drives the hosted bridge with a fake transport that hands the stream out +// in pieces, the way a file re-read once a frame does. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AcBridge, acModelLabel, errorMessage, parseSse, resolveAcModel } from "./aesel-ac.mjs"; + +function sse(events) { + return events.map((e) => `data: ${JSON.stringify(e)}\n`).join(""); +} + +// A transport whose stream the test feeds by hand. +function fakeTransport() { + const t = { + started: [], + text: "", + done: false, + error: "", + cancelled: 0, + accept: true, + start(body, headers) { + t.started.push({ body: JSON.parse(body), headers }); + t.text = ""; + t.done = false; + t.error = ""; + return t.accept; + }, + poll() { + return { text: t.text, done: t.done, error: t.error }; + }, + cancel() { + t.cancelled += 1; + }, + }; + return t; +} + +function make({ token = "tok", piece = "// blank\n" } = {}) { + const transport = fakeTransport(); + const writes = []; + const bridge = new AcBridge({ + transport, + token, + model: "glm", + instructions: "native rules", + file: "/pieces/lumo.mjs", + readPiece: () => piece, + writePiece: (source) => { writes.push(source); return true; }, + uuid: () => "s-1", + }); + return { bridge, transport, writes }; +} + +test("parseSse takes whole data lines only and leaves a torn tail for later", () => { + const text = 'data: {"a":1}\n: comment\ndata: [DONE]\ndata: {"b":2}\ndata: {"c":'; + const first = parseSse(text, 0); + assert.deepEqual(first.events, [{ a: 1 }, { b: 2 }]); + const second = parseSse(text + "3}\n", first.offset); + assert.deepEqual(second.events, [{ c: 3 }]); +}); + +test("models resolve by alias and label back", () => { + assert.equal(resolveAcModel("glm"), "z-ai/glm-4.6"); + assert.equal(resolveAcModel(""), "z-ai/glm-4.6"); + assert.equal(resolveAcModel("some/other"), "some/other"); + assert.equal(acModelLabel("qwen/qwen3-coder"), "qwen"); + assert.equal(acModelLabel("x/y-1"), "y-1"); + assert.equal(errorMessage('{"error":{"message":"used today\'s allowance"}}', "fallback"), "used today's allowance"); + assert.equal(errorMessage("", "fallback"), "fallback"); +}); + +test("no token: the handshake fails with a pointer to link", () => { + const { bridge } = make({ token: "" }); + bridge.launch(); + bridge.handshake(); + const [event] = bridge.take(); + assert.equal(event.type, "error"); + assert.equal(event.fatal, true); + assert.match(event.message, /link/); + assert.equal(bridge.ready, false); +}); + +test("a turn streams text, writes the piece through the tool, and goes round again", () => { + const { bridge, transport, writes } = make(); + bridge.launch(); + bridge.handshake(); + assert.equal(bridge.take()[0].type, "ready"); + + bridge.say("make it blue"); + assert.equal(transport.started.length, 1); + const body = transport.started[0].body; + assert.equal(body.model, "z-ai/glm-4.6"); + assert.equal(body.tools[0].name, "write_piece"); + assert.equal(body.system[0].text, "native rules"); + assert.match(body.system[1].text, /Current source of \/pieces\/lumo\.mjs/); + assert.deepEqual(body.messages, [{ role: "user", content: "make it blue" }]); + assert.equal(transport.started[0].headers.Authorization, "Bearer tok"); + + // Frame 1: half the stream, a torn line at the end. + const full = sse([ + { type: "content_block_start", index: 0, content_block: { type: "text" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Blue " } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "it is." } }, + { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "t1", name: "write_piece" } }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"source":"function paint({ wipe }) { wipe(0,0,255); }' } }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '\\nexport { paint };","note":"blue"}' } }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "tool_use" } }, + ]); + // Cut just after the first delta's line, so frame one carries exactly one delta. + transport.text = full.slice(0, full.indexOf("\n", full.indexOf("Blue ")) + 1); + bridge.tick(); + let events = bridge.take(); + assert.deepEqual(events.map((e) => e.type), ["turn", "delta"]); + assert.equal(events[1].text, "Blue "); + + // Frame 2: the rest, and the request ends. + transport.text = full; + transport.done = true; + bridge.tick(); + events = bridge.take(); + assert.deepEqual(events.map((e) => e.type), ["delta", "tool", "tool"]); + assert.equal(events[1].status, "started"); + assert.equal(events[2].status, "completed"); + assert.equal(events[2].path, "/pieces/lumo.mjs"); + assert.equal(events[2].label, "/pieces/lumo.mjs · blue"); + assert.equal(writes.length, 1); + assert.match(writes[0], /wipe\(0,0,255\)/); + assert.ok(writes[0].endsWith("\n")); + + // The second round carries the assistant blocks and the tool result. + assert.equal(transport.started.length, 2); + const again = transport.started[1].body.messages; + assert.equal(again.length, 3); + assert.equal(again[1].role, "assistant"); + assert.equal(again[1].content[0].text, "Blue it is."); + assert.equal(again[1].content[1].type, "tool_use"); + assert.equal(again[2].content[0].type, "tool_result"); + assert.equal(again[2].content[0].tool_use_id, "t1"); + assert.equal(bridge.busy, true); + + transport.text = sse([ + { type: "content_block_start", index: 0, content_block: { type: "text" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } }, + { type: "message_delta", delta: { stop_reason: "end_turn" } }, + ]); + transport.done = true; + bridge.tick(); + events = bridge.take(); + assert.deepEqual(events.map((e) => e.type), ["delta", "turn"]); + assert.equal(events[1].status, "completed"); + assert.equal(bridge.busy, false); + assert.equal(bridge.messages.length, 4); +}); + +test("an HTTP refusal ends the turn with the server's message", () => { + const { bridge, transport } = make(); + bridge.launch(); + bridge.handshake(); + bridge.take(); + bridge.say("go"); + transport.text = '{"error":{"message":"@jeffrey has used today\'s allowance"}}'; + transport.error = "request failed (22): curl: (22) The requested URL returned error: 429"; + transport.done = true; + bridge.tick(); + const events = bridge.take(); + assert.equal(events.at(-1).type, "turn"); + assert.equal(events.at(-1).status, "failed"); + assert.match(events.at(-1).error, /allowance/); + assert.equal(bridge.busy, false); +}); + +test("a busy device, an unknown tool, an interrupt", () => { + const { bridge, transport, writes } = make(); + bridge.launch(); + bridge.handshake(); + bridge.take(); + + transport.accept = false; + bridge.say("go"); + let events = bridge.take(); + assert.equal(events.at(-1).status, "failed"); + assert.match(events.at(-1).error, /in flight/); + transport.accept = true; + + bridge.say("again"); + bridge.take(); + transport.text = sse([ + { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "t9", name: "read_file" } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{}" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "tool_use" } }, + ]); + transport.done = true; + bridge.tick(); + events = bridge.take(); + assert.equal(events[0].type, "tool"); + assert.equal(events[0].status, "failed"); + assert.equal(writes.length, 0); + assert.equal(transport.started.at(-1).body.messages.at(-1).content[0].is_error, true); + + assert.equal(bridge.interrupt(), true); + assert.equal(transport.cancelled, 1); + assert.equal(bridge.take().at(-1).status, "interrupted"); + assert.equal(bridge.busy, false); + assert.equal(bridge.interrupt(), false); +}); diff --git a/fedac/native/lib/aesel-bridge.mjs b/fedac/native/lib/aesel-bridge.mjs new file mode 100644 index 0000000000..2cfa3c2d49 --- /dev/null +++ b/fedac/native/lib/aesel-bridge.mjs @@ -0,0 +1,417 @@ +// aesel-bridge.mjs — the Claude engine bridge, for a piece. +// +// This is easel/src/claude-server.mjs with the process taken out. Easel's +// bridge owns a child process and an event emitter; a native piece has +// neither. What it has is a raw PTY that hands it whole lines once a frame +// (system.pty2.lines) and a write() for lines going the other way. So this +// module is the translator alone: feed it the lines Claude prints, it queues +// plain events for the piece to draw, and it hands back the lines to send. +// +// The protocol is Claude Code's headless stream-json — +// `claude --print --input-format stream-json --output-format stream-json` — +// and the two approval flags are load-bearing for the same reasons they are +// in Easel: `--permission-prompt-tool stdio` makes this process the one that +// answers permission prompts (without it every prompt is auto-denied and the +// model narrates that it lacks permission), and `--setting-sources ""` keeps +// the account's allow-lists, hooks and MCP servers out of a session where the +// person at the keyboard should be the only thing that can approve a command. +// +// Pure, so `node --test` can drive it with a scripted transcript. Nothing in +// here touches the runtime; the piece does the spawning, reading and writing. + +export const DEFAULT_MODEL = "claude-opus-5"; + +// Tools whose work is a file change, and the input field naming the file. +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 surface has no room for: two that leave the machine on their +// own, and one that hides a whole second conversation behind a single line. +export 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 ""; +} + +// A v4 UUID from whatever random source the runtime has. QuickJS has no +// crypto module; Math.random is what a session id needs here — it names the +// conversation on this machine, it does not secure anything. +export function uuid(random = Math.random) { + const hex = "0123456789abcdef"; + let out = ""; + for (let i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) out += "-"; + else if (i === 14) out += "4"; + else if (i === 19) out += hex[8 + Math.floor(random() * 4)]; + else out += hex[Math.floor(random() * 16)]; + } + return out; +} + +// Easel's pronounceable piece names, without node:crypto. +const CONSONANTS = "bdfgklmnprstvz"; +const VOWELS = "aeiou"; +export function randomSlug(random = Math.random) { + const count = 2 + (random() < 0.5 ? 0 : 1); + let name = ""; + for (let i = 0; i < count; i++) { + name += CONSONANTS[Math.floor(random() * CONSONANTS.length)]; + name += VOWELS[Math.floor(random() * VOWELS.length)]; + } + return name; +} + +// The blank a session opens on. Flat colour, nothing else, so the first edit +// the agent makes is visible as a change rather than a replacement. +export function blankPiece(slug, stamp = new Date().toISOString().slice(0, 10)) { + return [ + `// ${slug}, ${stamp}`, + "// A blank Aesthetic Computer piece, on AC Native.", + "", + "function paint({ wipe }) {", + " wipe(70, 50, 100);", + "}", + "", + "export { paint };", + "", + ].join("\n"); +} + +// What the agent is told about where it is. The native API is the web +// piece API's small cousin, and the difference is the whole point of saying +// anything: an agent that assumes the browser writes `fetch` and `document` +// into a piece that will run on a framebuffer. +export function instructionsFor({ handle = "", slug, file, screen = { width: 455, height: 256 }, writeTool = false }) { + const account = handle + ? `The user is @${handle}, signed in on this device.` + : "The device has no handle; the user is anonymous."; + return [ + "You are running inside Aesel on an Aesthetic Computer Native device: a laptop booted into AC Native OS (a small Linux, QuickJS, software framebuffer, no browser, no Node.js).", + account, + `This session's piece is ${file}. It already exists as a blank piece that paints a flat colour and nothing else. Edit that file unless the user asks for something else.`, + "The user runs the piece on this machine with one key, so small frequent edits are better than one big rewrite.", + "A native piece is an ES module exporting lifecycle functions: boot({ system, screen, params }), paint({ wipe, ink, box, line, circle, plot, write, screen, paintCount, sound, wifi, system }), act({ event, system, sound }), sim(), leave(). paint runs every frame at 60 fps; keep state in module variables.", + `The screen is small: about ${screen.width}x${screen.height} logical pixels. Read screen.width and screen.height rather than assuming.`, + 'Graphics: wipe(r, g, b) clears; ink(r, g, b) sets the colour for what follows; box(x, y, w, h) fills, box(x, y, w, h, "outline") strokes; line(x1, y1, x2, y2); circle(x, y, r, filled); plot(x, y). write(text, { x, y, size, font }) with font "6x10" (glyphs 6 wide, 10 tall) or "unifont". There is no DOM, no fetch(), no npm, no canvas context.', + 'Input, in act: event.is("keyboard:down") with event.key ("a", "space", "enter", "backspace", "arrowup", ...), event.is("keyboard:down:shift") for modifiers, and event.is("touch"), event.is("draw"), event.is("lift") with event.x and event.y. Escape returns the user to the prompt on its own; do not handle it.', + 'Sound: sound.synth({ type: "sine" | "square" | "triangle" | "sawtooth", tone: hertz, duration: seconds, volume: 0..1, attack, decay }).', + writeTool + ? "You cannot read or list files here. The piece's current source is given to you in this prompt. Your only tool is write_piece: send the complete new source of the file every time, never a patch." + : "The other files in /pieces are the reference for this API. Read chat.mjs or clock.mjs before guessing at a call; do not edit them.", + "Do not write the piece's name onto the screen. Do not try to run the piece with node or a browser; the device runs it.", + ].join("\n"); +} + +// The command line for the bridge child. `cwd` is where the child runs and +// where the piece lives; the session id is minted by the caller so the +// conversation has a name before its first turn. +export function launchArguments({ model = DEFAULT_MODEL, cwd, instructions = "", sessionId, resume = "" }) { + const args = [ + "--print", + "--input-format", "stream-json", + "--output-format", "stream-json", + "--include-partial-messages", + "--verbose", + "--model", model, + "--permission-mode", "manual", + "--permission-prompts", "host", + "--permission-prompt-tool", "stdio", + "--setting-sources", "", + "--strict-mcp-config", + "--disallowed-tools", ...WITHHELD_TOOLS, + "--add-dir", cwd, + ]; + if (instructions) args.push("--append-system-prompt", instructions); + if (resume) args.push("--resume", resume); + else args.push("--session-id", sessionId); + return args; +} + +// A line from the child may carry a spinner or an escape sequence ahead of +// its JSON when the CLI thought it had a terminal. Everything before the +// first brace is the CLI talking; the brace onward is addressed to us. +function splitLine(line) { + const trimmed = line.trim(); + if (!trimmed) return null; + const brace = trimmed.indexOf("{"); + if (brace < 0) return { log: trimmed }; + const json = trimmed.slice(brace); + try { + return { message: JSON.parse(json), log: brace > 0 ? trimmed.slice(0, brace).trim() : "" }; + } catch (error) { + if (brace === 0) return { protocolError: `invalid engine message: ${error.message}` }; + return { log: trimmed }; + } +} + +export class Bridge { + constructor({ send, model = DEFAULT_MODEL, cwd, instructions = "", uuid: mint = uuid }) { + this.send = send; // (line: string) => void — one JSON line, no newline + this.model = model; + this.cwd = cwd; + this.instructions = instructions; + this.mint = mint; + this.sessionId = ""; + this.events = []; + this.turns = 0; + this.controls = 0; + this.turnId = null; + this.handshakeId = ""; + this.ready = false; + this.announced = false; + this.items = new Map(); // tool_use id → item, so a result completes its line + this.approvals = new Map(); // request id → {tool, input, suggestions} + this.messageId = ""; + this.textBlocks = new Set(); + this.streamedText = false; + } + + // Arguments for the child, and a fresh session id. Call before spawning; + // then, once the child is up, handshake(). + launch(resume = "") { + this.sessionId = resume || this.mint(); + this.items.clear(); + this.approvals.clear(); + this.turnId = null; + this.ready = false; + this.announced = false; + return launchArguments({ + model: this.model, + cwd: this.cwd, + instructions: this.instructions, + sessionId: this.sessionId, + resume, + }); + } + + // The control handshake that registers this process as the host of the + // permission channel. Its answer is the bridge saying it is ready. + handshake() { + this.handshakeId = `ac-${++this.controls}`; + this.#send({ type: "control_request", request_id: this.handshakeId, request: { subtype: "initialize", hooks: {} } }); + } + + get busy() { + return this.turnId !== null; + } + + get pendingApproval() { + for (const [id, approval] of this.approvals) return { id, ...approval }; + return null; + } + + say(text) { + this.turnId = `turn-${++this.turns}`; + this.#send({ + type: "user", + message: { role: "user", content: [{ type: "text", text }] }, + parent_tool_use_id: null, + session_id: this.sessionId, + }); + this.#emit({ type: "turn", status: "started", id: this.turnId }); + } + + interrupt() { + if (!this.turnId) return false; + this.#send({ type: "control_request", request_id: `ac-${++this.controls}`, request: { subtype: "interrupt" } }); + return true; + } + + // y → accept once, a → accept for the session, n → decline. Accepting for + // the session pins the rules Claude suggested for the call to this session + // alone; nothing is written to a settings file. + approve(id, decision) { + const approval = this.approvals.get(id); + if (!approval) return false; + this.approvals.delete(id); + let response; + if (decision === "accept" || decision === "acceptForSession") { + response = { behavior: "allow", updatedInput: approval.input }; + if (decision === "acceptForSession") response.updatedPermissions = sessionRules(approval); + } else { + response = { behavior: "deny", message: "Denied in Aesel." }; + } + this.#send({ type: "control_response", response: { subtype: "success", request_id: id, response } }); + this.#emit({ type: "approval-resolved", id, decision }); + return true; + } + + // Take the events queued since the last take. + take() { + const out = this.events; + this.events = []; + return out; + } + + // One line from the child's stdout. + feed(line) { + const parts = splitLine(line); + if (!parts) return; + if (parts.log) this.#emit({ type: "log", line: parts.log }); + if (parts.protocolError) this.#emit({ type: "error", message: parts.protocolError }); + if (parts.message) this.#receive(parts.message); + } + + #send(message) { + this.send(JSON.stringify(message)); + } + + #emit(event) { + this.events.push(event); + } + + #receive(message) { + switch (message.type) { + case "control_response": return this.#controlResponse(message); + 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 "result": return this.#result(message); + default: + } + } + + #controlResponse(message) { + const response = message.response || {}; + if (response.request_id !== this.handshakeId) return; + if (response.subtype === "error") { + this.#emit({ type: "error", message: response.error || "engine bridge refused the handshake", fatal: true }); + return; + } + this.ready = true; + this.#emit({ type: "ready", model: this.model, session: this.sessionId }); + } + + // `system/init` opens each turn with what the CLI resolved the model and + // session to. Say so once if the model differs. + #system(message) { + if (message.subtype !== "init" || this.announced) return; + this.announced = true; + if (message.session_id) this.sessionId = message.session_id; + if (message.model && message.model !== this.model) { + this.model = message.model; + this.#emit({ type: "warning", message: `Running ${message.model}` }); + } + } + + #streamEvent(event) { + if (event.type === "message_start") { + this.messageId = event.message?.id || `message-${this.turns}`; + this.textBlocks.clear(); + this.streamedText = false; + return; + } + if (event.type === "content_block_start" && event.content_block?.type === "text") { + this.textBlocks.add(event.index); + return; + } + if (event.type === "content_block_delta" && event.delta?.type === "text_delta") { + if (!this.textBlocks.has(event.index)) return; + this.streamedText = true; + this.#emit({ type: "delta", text: event.delta.text }); + } + } + + #assistant(message) { + const content = message.message?.content || []; + // A rate limit, a usage cap, or a refused model arrives as a synthetic + // assistant message with no stream behind it; it is the only explanation + // the session will get, since the turn that follows only says "interrupted". + if (message.is_api_error_message) { + const text = content.filter((b) => b.type === "text" && b.text).map((b) => b.text).join(" ").trim(); + this.#emit({ type: "error", message: text || "the engine refused the turn" }); + return; + } + for (const block of content) { + if (block.type === "tool_use") { + const item = toolItem(block); + this.items.set(block.id, item); + this.#emit({ type: "tool", status: "started", ...item }); + } else if (block.type === "text" && block.text && !this.streamedText) { + // Text that never streamed still reaches the screen. + this.#emit({ type: "message", text: block.text }); + } + } + } + + #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); + this.#emit({ type: "tool", status: block.is_error ? "failed" : "completed", ...item }); + } + } + + #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: `Aesel 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({ type: "approval", id, kind: "file", label: request.input?.[field] || request.description || request.tool_name }); + return; + } + const label = COMMAND_TOOLS.has(request.tool_name) + ? request.input?.command + : `${request.tool_name}${request.description ? ` · ${request.description}` : ""}`; + this.#emit({ type: "approval", id, kind: "command", label: label || request.tool_name }); + } + + #result(message) { + const id = this.turnId || `turn-${this.turns}`; + this.turnId = null; + this.textBlocks.clear(); + const aborted = String(message.terminal_reason || "").startsWith("aborted"); + const status = aborted ? "interrupted" : message.is_error ? "failed" : "completed"; + const event = { type: "turn", status, id }; + if (status === "failed") event.error = String(message.result || message.terminal_reason || "turn failed"); + this.#emit(event); + } +} + +function sessionRules({ tool, suggestions }) { + const rules = (suggestions || []) + .filter((s) => s?.type === "addRules" || s?.type === "setMode") + .map((s) => ({ ...s, destination: "session" })); + if (rules.length) return rules; + return [{ type: "addRules", rules: [{ toolName: tool }], behavior: "allow", destination: "session" }]; +} + +function toolItem(block) { + const field = FILE_TOOLS.get(block.name); + if (field) { + const path = block.input?.[field] || ""; + return { id: block.id, kind: "file", label: path || block.name, path }; + } + if (COMMAND_TOOLS.has(block.name)) { + return { id: block.id, kind: "command", label: block.input?.command || block.name }; + } + const detail = toolDetail(block.input); + return { id: block.id, kind: "call", label: detail ? `${block.name} · ${detail}` : block.name }; +} diff --git a/fedac/native/lib/aesel-bridge.test.mjs b/fedac/native/lib/aesel-bridge.test.mjs new file mode 100644 index 0000000000..915e9c00d7 --- /dev/null +++ b/fedac/native/lib/aesel-bridge.test.mjs @@ -0,0 +1,155 @@ +// node --test fedac/native/lib/aesel-bridge.test.mjs +// +// Drives the bridge with a scripted transcript of Claude's stream-json +// protocol and checks what the piece would be told and what the child would +// be sent. No runtime, no process: the same file the device loads. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { Bridge, blankPiece, instructionsFor, launchArguments, randomSlug, uuid } from "./aesel-bridge.mjs"; + +function make() { + const sent = []; + const bridge = new Bridge({ + send: (line) => sent.push(JSON.parse(line)), + cwd: "/pieces", + instructions: "be brief", + uuid: () => "00000000-0000-4000-8000-000000000000", + }); + return { bridge, sent }; +} + +test("launch names the session, withholds the tools, and points at the workspace", () => { + const { bridge } = make(); + const args = bridge.launch(); + assert.equal(args[args.indexOf("--session-id") + 1], "00000000-0000-4000-8000-000000000000"); + assert.equal(args[args.indexOf("--permission-prompt-tool") + 1], "stdio"); + assert.equal(args[args.indexOf("--setting-sources") + 1], ""); + assert.deepEqual(args.slice(args.indexOf("--disallowed-tools") + 1, args.indexOf("--add-dir")), ["WebFetch", "WebSearch", "Task"]); + assert.equal(args[args.indexOf("--add-dir") + 1], "/pieces"); + assert.equal(args[args.indexOf("--append-system-prompt") + 1], "be brief"); + assert.ok(!args.includes("--resume")); + const resumed = launchArguments({ cwd: "/pieces", sessionId: "x", resume: "abc" }); + assert.equal(resumed[resumed.indexOf("--resume") + 1], "abc"); + assert.ok(!resumed.includes("--session-id")); +}); + +test("the handshake answer makes the bridge ready", () => { + const { bridge, sent } = make(); + bridge.launch(); + bridge.handshake(); + assert.equal(sent[0].type, "control_request"); + assert.equal(sent[0].request.subtype, "initialize"); + assert.equal(bridge.ready, false); + bridge.feed(JSON.stringify({ type: "control_response", response: { subtype: "success", request_id: sent[0].request_id } })); + assert.equal(bridge.ready, true); + assert.deepEqual(bridge.take().map((e) => e.type), ["ready"]); +}); + +test("a turn streams text, reports tools, and completes", () => { + const { bridge, sent } = make(); + bridge.launch(); + bridge.say("make it blue"); + assert.equal(sent[0].type, "user"); + assert.equal(sent[0].message.content[0].text, "make it blue"); + assert.equal(bridge.busy, true); + + const feed = (m) => bridge.feed(JSON.stringify(m)); + feed({ type: "system", subtype: "init", model: "claude-opus-5", session_id: "s1" }); + feed({ type: "stream_event", event: { type: "message_start", message: { id: "m1" } } }); + feed({ type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "text" } } }); + feed({ type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Sure" } } }); + feed({ type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: ", blue." } } }); + feed({ type: "assistant", message: { id: "m1", content: [ + { type: "text", text: "Sure, blue." }, + { type: "tool_use", id: "t1", name: "Edit", input: { file_path: "/pieces/lumo.mjs" } }, + ] } }); + feed({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "t1" }] } }); + feed({ type: "result", is_error: false }); + + const events = bridge.take(); + assert.deepEqual(events.map((e) => e.type), ["turn", "delta", "delta", "tool", "tool", "turn"]); + assert.equal(events[1].text + events[2].text, "Sure, blue."); + assert.equal(events[3].kind, "file"); + assert.equal(events[3].path, "/pieces/lumo.mjs"); + assert.equal(events[4].status, "completed"); + assert.equal(events[5].status, "completed"); + assert.equal(bridge.busy, false); + assert.equal(bridge.sessionId, "s1"); +}); + +test("an approval is asked once and answered with the caller's decision", () => { + const { bridge, sent } = make(); + bridge.launch(); + bridge.feed(JSON.stringify({ type: "control_request", request_id: "r1", request: { + subtype: "can_use_tool", tool_name: "Bash", input: { command: "ls /pieces" }, + permission_suggestions: [{ type: "addRules", rules: [{ toolName: "Bash", ruleContent: "ls:*" }], behavior: "allow" }], + } })); + const [approval] = bridge.take(); + assert.equal(approval.type, "approval"); + assert.equal(approval.kind, "command"); + assert.equal(approval.label, "ls /pieces"); + assert.equal(bridge.pendingApproval.id, "r1"); + + bridge.approve("r1", "acceptForSession"); + const reply = sent.at(-1); + assert.equal(reply.type, "control_response"); + assert.equal(reply.response.request_id, "r1"); + assert.equal(reply.response.response.behavior, "allow"); + assert.deepEqual(reply.response.response.updatedInput, { command: "ls /pieces" }); + assert.equal(reply.response.response.updatedPermissions[0].destination, "session"); + assert.equal(bridge.pendingApproval, null); + assert.equal(bridge.approve("r1", "accept"), false); + + bridge.feed(JSON.stringify({ type: "control_request", request_id: "r2", request: { + subtype: "can_use_tool", tool_name: "Write", input: { file_path: "/pieces/lumo.mjs", content: "x" }, + } })); + const [resolved, file] = bridge.take(); + assert.equal(resolved.type, "approval-resolved"); + assert.equal(file.kind, "file"); + assert.equal(file.label, "/pieces/lumo.mjs"); + bridge.approve("r2", "decline"); + assert.equal(sent.at(-1).response.response.behavior, "deny"); +}); + +test("chatter ahead of a message is logged, chatter alone is logged, bad JSON is an error", () => { + const { bridge } = make(); + bridge.launch(); + bridge.feed("\u001b[2K⠋ thinking{\"type\":\"result\",\"is_error\":false}"); + bridge.feed("Connector notice: nothing to see"); + bridge.feed("{not json"); + bridge.feed(" "); + const events = bridge.take(); + assert.deepEqual(events.map((e) => e.type), ["log", "turn", "log", "error"]); + assert.match(events[3].message, /invalid engine message/); +}); + +test("an api error message and an aborted result read as error and interruption", () => { + const { bridge } = make(); + bridge.launch(); + bridge.say("go"); + bridge.take(); + bridge.feed(JSON.stringify({ type: "assistant", is_api_error_message: true, message: { content: [{ type: "text", text: "out of credits" }] } })); + bridge.feed(JSON.stringify({ type: "result", terminal_reason: "aborted_by_user" })); + const events = bridge.take(); + assert.equal(events[0].type, "error"); + assert.equal(events[0].message, "out of credits"); + assert.equal(events[1].status, "interrupted"); + assert.equal(bridge.interrupt(), false); +}); + +test("helpers: slug, uuid, blank and instructions", () => { + let n = 0; + const seq = [0.1, 0.3, 0.6, 0.9, 0.2, 0.5, 0.7]; + const slug = randomSlug(() => seq[n++ % seq.length]); + assert.match(slug, /^([bdfgklmnprstvz][aeiou]){2,3}$/); + const id = uuid(() => 0.5); + assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + const blank = blankPiece("lumo", "2026-09-23"); + assert.match(blank, /^\/\/ lumo, 2026-09-23\n/); + assert.match(blank, /export \{ paint \};/); + const text = instructionsFor({ handle: "jeffrey", slug: "lumo", file: "/pieces/lumo.mjs" }); + assert.match(text, /@jeffrey/); + assert.match(text, /\/pieces\/lumo\.mjs/); + assert.match(text, /no browser/); +}); diff --git a/fedac/native/pieces/aesel.mjs b/fedac/native/pieces/aesel.mjs new file mode 100644 index 0000000000..b610352850 --- /dev/null +++ b/fedac/native/pieces/aesel.mjs @@ -0,0 +1,567 @@ +// aesel.mjs — Aesel, on AC Native. +// +// The desktop Aesel is an Electron shell around a conversation with an agent +// and the piece it is writing. This machine has no Electron and no browser; +// it has a framebuffer, a keyboard, QuickJS, and the Claude Code binary. So +// this is the same session drawn as a piece: the transcript in the middle, a +// pink prompt at the bottom, approvals answered with y / a / n, and the piece +// the agent writes sitting in /pieces where one key runs it on this machine. +// +// The bridge is Claude's headless stream-json protocol over a raw PTY +// (system.pty2 with { raw: true }); lib/aesel-bridge.mjs translates it. The +// child stays alive when the user leaves for the prompt or runs the piece — +// `aesel` from the prompt reattaches to the same conversation. + +import { Bridge, DEFAULT_MODEL, blankPiece, instructionsFor, randomSlug } from "../lib/aesel-bridge.mjs"; +import { AcBridge, DEFAULT_AC_MODEL, acModelLabel, resolveAcModel } from "../lib/aesel-ac.mjs"; + +const PALETTE = { + background: [70, 50, 100], + text: [255, 255, 255], + prompt: [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], +}; + +const FONT = "6x10"; +const CW = 6; +const CH = 10; +const PAD = 4; +const CLAUDE = "/bin/claude"; +const PIECES = "/pieces"; +// The hosted engine: aesthetic.computer buys the inference and meters it +// against the device's @handle. Its stream lands in this file, re-read once a +// frame (see acTransport), because the runtime's fetch is curl, not fetch(). +const SITE = "https://aesthetic.computer"; +const STREAM_FILE = "/tmp/aesel-inference.sse"; +const MAX_ENTRIES = 400; + +const SHIFT_MAP = { + "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", "8": "*", "9": "(", "0": ")", + "-": "_", "=": "+", "[": "{", "]": "}", ";": ":", "'": '"', ",": "<", ".": ">", "/": "?", "\\": "|", "`": "~", +}; + +// Session state lives on globalThis so it survives a jump to the prompt or +// to the piece being written; the module itself is re-evaluated on return. +let S = null; +let shiftHeld = false; +let ctrlHeld = false; +// The system object of the current paint, for the hosted transport's polling. +let SYS = null; +let frame = 0; + +// `aesel` → Claude on the default model; `aesel:ac` or `aesel:ac:qwen` → the +// hosted engine; `aesel:claude:` or `aesel:` → Claude on that model. +function parseParams(params) { + const [first, second] = params || []; + if (first === "ac" || first === "aesthetic" || first === "hosted") { + return { backend: "ac", model: resolveAcModel(second || "") }; + } + if (first === "claude") return { backend: "claude", model: second || DEFAULT_MODEL }; + return { backend: "claude", model: first || DEFAULT_MODEL }; +} + +function freshState(system, params) { + const handle = readHandle(system); + const slug = randomSlug(); + const file = `${PIECES}/${slug}.mjs`; + const { backend, model } = parseParams(params); + return { + handle, + token: readToken(system), + backend, + slug, + file, + model, + bridge: null, + entries: [], // {kind, text, id?, status?, label?} + input: "", + cursor: 0, + scroll: 0, // lines scrolled back from the bottom + status: "starting", + pending: null, // approval waiting for y / a / n + pieceDirty: false, + pieceWrites: 0, + exitCode: null, + }; +} + +function readHandle(system) { + let handle = system?.config?.handle || ""; + if (!handle) { + try { + const raw = system.readFile("/mnt/config.json"); + if (raw) handle = JSON.parse(raw).handle || ""; + } catch (_) {} + } + return handle === "anonymous" ? "" : handle; +} + +// The AC access token the `link` pairing wrote into config.json. It pays for +// the hosted engine; a device flashed without linking has none. +function readToken(system) { + try { + const raw = system.readFile("/mnt/config.json"); + if (raw) return JSON.parse(raw).token || ""; + } catch (_) {} + return ""; +} + +// The hosted engine's transport over the runtime's curl-backed fetch. start() +// hands curl a file to stream into; poll() re-reads that file. The request is +// not "done" on the frame it started, because the system object of that paint +// was built before curl existed. +function acTransport() { + let startedAt = -1; + return { + start(body, headers) { + startedAt = frame; + const ok = SYS.fetchPost(`${SITE}/api/easel-inference`, body, JSON.stringify(headers), { out: STREAM_FILE, timeout: 600 }); + return ok === true; + }, + poll() { + const text = SYS.readFile(STREAM_FILE) || ""; + const done = frame > startedAt && !SYS.fetchPending; + return { text, done, error: done ? (SYS.fetchError || "") : "" }; + }, + cancel() { + SYS.fetchCancel(); + }, + }; +} + +function note(text, kind = "note") { + S.entries.push({ kind, text }); + if (S.entries.length > MAX_ENTRIES) S.entries.splice(0, S.entries.length - MAX_ENTRIES); + S.scroll = 0; +} + +function ensurePiece(system) { + const existing = system.readFile(S.file); + if (existing) return; + system.writeFile(S.file, blankPiece(S.slug)); +} + +function spawnBridge(system, screen, resume = "") { + SYS = system; + if (S.bridge instanceof AcBridge && S.bridge.busy) S.bridge.interrupt(); + if (S.backend === "ac") { + if (system.pty2.active) system.pty2.kill(); // a Claude child from before the switch + S.bridge = new AcBridge({ + transport: acTransport(), + token: S.token, + model: S.model, + instructions: instructionsFor({ handle: S.handle, slug: S.slug, file: S.file, screen, writeTool: true }), + file: S.file, + readPiece: () => SYS.readFile(S.file) || "", + writePiece: (source) => SYS.writeFile(S.file, source) === true, + }); + S.bridge.launch(); + S.status = "starting"; + S.exitCode = null; + S.bridge.handshake(); + return; + } + S.bridge = new Bridge({ + send: (line) => system.pty2.write(line + "\n"), + model: S.model, + cwd: PIECES, + instructions: instructionsFor({ handle: S.handle, slug: S.slug, file: S.file, screen }), + }); + const args = S.bridge.launch(resume); + S.status = "starting"; + S.exitCode = null; + const ok = system.pty2.spawn(CLAUDE, args, 200, 50, { raw: true, cwd: PIECES }); + if (!ok) { + S.status = "error"; + note("could not start " + CLAUDE, "error"); + return; + } + S.bridge.handshake(); +} + +function boot({ system, screen, params }) { + const kept = globalThis.__aesel; + if (kept && kept.bridge && (kept.backend === "ac" || (system.pty2 && system.pty2.active))) { + S = kept; // Reattach: the conversation kept running while we were away. + S.input = ""; + S.cursor = 0; + return; + } + S = freshState(system, params); + globalThis.__aesel = S; + ensurePiece(system); + note(`piece ${S.file} · engine ${S.backend === "ac" ? "aesthetic" : "claude"}`); + note("type to talk · enter sends · tab runs the piece here · esc back to prompt · /help"); + // The child signs in with the OAuth token the flash baked in. Without one + // the first turn fails with an auth error that says nothing about why. + if (S.backend === "claude" && !system.readFile("/claude-token")) { + note("no Claude token on this device: flash it with ac-os (not AC_ANON) to bake one", "error"); + } + spawnBridge(system, screen); +} + +// ── events from the bridge ────────────────────────────────────────────── + +function absorb(event) { + switch (event.type) { + case "ready": + S.status = "ready"; + return; + case "turn": + if (event.status === "started") S.status = "thinking"; + else { + S.status = "ready"; + if (event.status === "failed") note(event.error || "turn failed", "error"); + else if (event.status === "interrupted") note("stopped", "note"); + } + return; + case "delta": { + const last = S.entries[S.entries.length - 1]; + if (last && last.kind === "agent" && last.open) last.text += event.text; + else S.entries.push({ kind: "agent", text: event.text, open: true }); + S.scroll = 0; + return; + } + case "message": + closeAgent(); + note(event.text, "agent"); + return; + case "tool": { + closeAgent(); + const existing = S.entries.find((e) => e.kind === "tool" && e.id === event.id); + if (existing) { + existing.status = event.status; + } else { + S.entries.push({ kind: "tool", id: event.id, tool: event.kind, label: event.label, status: event.status }); + } + if (event.kind === "file" && event.status === "completed" && event.path === S.file) { + S.pieceDirty = true; + S.pieceWrites += 1; + } + S.scroll = 0; + return; + } + case "approval": + S.pending = { id: event.id, kind: event.kind, label: event.label }; + return; + case "approval-resolved": + if (S.pending && S.pending.id === event.id) S.pending = null; + return; + case "warning": + note(event.message, "soft"); + return; + case "error": + note(event.message, "error"); + if (event.fatal) S.status = "error"; + return; + case "log": + S.lastLog = event.line; + return; + default: + } +} + +function closeAgent() { + const last = S.entries[S.entries.length - 1]; + if (last && last.kind === "agent") last.open = false; +} + +// ── keyboard ──────────────────────────────────────────────────────────── + +function command(line, system, screen) { + const [name, ...rest] = line.slice(1).split(/\s+/); + const arg = rest.join(" ").trim(); + switch (name) { + case "help": + note("/new fresh conversation · /run · /piece · /model · /backend ac|claude · /quit"); + return; + case "backend": { + const target = arg === "aesthetic" || arg === "hosted" || arg === "free" ? "ac" : arg; + if (target !== "ac" && target !== "claude") { note(`engine ${S.backend} · /backend ac|claude`); return; } + if (target === S.backend) { note(`already on ${target}`); return; } + S.backend = target; + S.model = target === "ac" ? DEFAULT_AC_MODEL : DEFAULT_MODEL; + note(`engine ${target === "ac" ? "aesthetic" : "claude"}`); + spawnBridge(system, screen, ""); + return; + } + case "new": + note("new conversation"); + spawnBridge(system, screen, ""); + return; + case "run": + runPiece(system); + return; + case "piece": + if (!/^[a-z0-9-]+$/.test(arg)) { note("usage: /piece ", "error"); return; } + S.slug = arg; + S.file = `${PIECES}/${arg}.mjs`; + S.pieceDirty = false; + ensurePiece(system); + note(`piece ${S.file}`); + spawnBridge(system, screen, ""); // the instructions name the file + return; + case "model": + if (!arg) { note(`model ${S.model}`); return; } + S.model = S.backend === "ac" ? resolveAcModel(arg) : arg; + note(`model ${arg}`); + spawnBridge(system, screen, ""); + return; + case "quit": + if (S.bridge && S.bridge.busy) S.bridge.interrupt(); + if (system.pty2.active) system.pty2.kill(); + globalThis.__aesel = null; + system.jump("prompt"); + return; + default: + note(`unknown command /${name}`, "error"); + } +} + +function runPiece(system) { + if (!system.readFile(S.file)) { note("nothing to run yet", "error"); return; } + S.pieceDirty = false; + system.jump(S.slug); +} + +function act({ event: e, system, screen }) { + if (e.is("keyboard:down:shift")) { shiftHeld = true; return; } + if (e.is("keyboard:up:shift")) { shiftHeld = false; return; } + if (e.is("keyboard:down:control")) { ctrlHeld = true; return; } + if (e.is("keyboard:up:control")) { ctrlHeld = false; return; } + if (!e.is("keyboard:down") || !S) return; + const key = e.key; + + if (key === "escape") { system.jump("prompt"); return; } + + if (ctrlHeld && key === "c") { + if (S.bridge && S.bridge.busy) { S.bridge.interrupt(); note("stopping…", "soft"); } + else { S.input = ""; S.cursor = 0; } + return; + } + + // An approval owns the keyboard until it is answered. + if (S.pending && S.input === "") { + const decision = key === "y" ? "accept" : key === "a" ? "acceptForSession" : key === "n" ? "decline" : null; + if (decision) { + const pending = S.pending; + S.pending = null; + S.bridge.approve(pending.id, decision); + note(`${decision === "decline" ? "denied" : "allowed"} ${pending.label}`, "soft"); + return; + } + } + + if (key === "tab") { runPiece(system); return; } + if (key === "arrowup") { S.scroll += 1; return; } + if (key === "arrowdown") { S.scroll = Math.max(0, S.scroll - 1); return; } + if (key === "pageup") { S.scroll += 10; return; } + if (key === "pagedown") { S.scroll = Math.max(0, S.scroll - 10); return; } + + if (key === "enter" || key === "return") { + const text = S.input.trim(); + S.input = ""; + S.cursor = 0; + if (!text) return; + if (text.startsWith("/")) { command(text, system, screen); return; } + if (!S.bridge || !S.bridge.ready) { note("the engine is still starting", "error"); return; } + if (S.bridge.busy) { note("wait for the turn to finish, or ctrl+c", "error"); return; } + closeAgent(); + note(text, "user"); + S.bridge.say(text); + return; + } + + if (key === "backspace") { + if (S.cursor > 0) { S.input = S.input.slice(0, S.cursor - 1) + S.input.slice(S.cursor); S.cursor--; } + return; + } + if (key === "arrowleft") { if (S.cursor > 0) S.cursor--; return; } + if (key === "arrowright") { if (S.cursor < S.input.length) S.cursor++; return; } + if (key === "home") { S.cursor = 0; return; } + if (key === "end") { S.cursor = S.input.length; return; } + + let ch = null; + if (key === "space") ch = " "; + else if (key.length === 1) ch = shiftHeld ? (SHIFT_MAP[key] ?? key.toUpperCase()) : key; + if (ch !== null) { + S.input = S.input.slice(0, S.cursor) + ch + S.input.slice(S.cursor); + S.cursor++; + } +} + +// ── drawing ───────────────────────────────────────────────────────────── + +function wrap(text, cols) { + const out = []; + for (const para of String(text).split("\n")) { + let line = ""; + for (const word of para.split(" ")) { + if (word.length > cols) { + if (line) { out.push(line); line = ""; } + for (let i = 0; i < word.length; i += cols) out.push(word.slice(i, i + cols)); + continue; + } + const next = line ? line + " " + word : word; + if (next.length > cols) { out.push(line); line = word; } + else line = next; + } + out.push(line); + } + return out; +} + +// Turn the transcript into coloured lines, newest last. +function layout(cols) { + const lines = []; + for (const entry of S.entries) { + if (entry.kind === "user") { + wrap(entry.text, cols - 2).forEach((l, i) => lines.push({ text: (i ? " " : "> ") + l, rgb: PALETTE.text, mark: i ? null : PALETTE.prompt })); + } else if (entry.kind === "agent") { + wrap(entry.text, cols).forEach((l) => lines.push({ text: l, rgb: PALETTE.text })); + } else if (entry.kind === "tool") { + const verb = entry.tool === "file" ? "write" : entry.tool === "command" ? "$" : "·"; + const tail = entry.status === "started" ? " …" : entry.status === "failed" ? " ✗" : ""; + const rgb = entry.status === "failed" ? PALETTE.error : PALETTE.muted; + wrap(`${verb} ${entry.label}${tail}`, cols).forEach((l) => lines.push({ text: l, rgb })); + } else if (entry.kind === "error") { + wrap(entry.text, cols).forEach((l) => lines.push({ text: l, rgb: PALETTE.error })); + } else if (entry.kind === "soft") { + wrap(entry.text, cols).forEach((l) => lines.push({ text: l, rgb: PALETTE.soft })); + } else { + wrap(entry.text, cols).forEach((l) => lines.push({ text: l, rgb: PALETTE.muted })); + } + } + return lines; +} + +function paint({ wipe, ink, box, write, screen, system, wifi, paintCount }) { + if (!S) return; + frame++; + SYS = system; + const W = screen.width; + const H = screen.height; + + // Pump the bridge. Claude's lines are handed out once per paint, so every + // paint reads them; the hosted engine polls its stream file on tick(). + const pty = system.pty2; + if (S.bridge) { + if (S.backend === "ac") { + S.bridge.tick(); + } else if (pty) { + if (pty.lines && pty.lines.length) for (const line of pty.lines) S.bridge.feed(line); + if (pty.overflow) note("bridge output overflowed; a message was lost", "error"); + if (!pty.active && S.status !== "error" && S.status !== "closed") { + S.status = "closed"; + S.exitCode = pty.exitCode; + note(`engine bridge closed (${pty.exitCode}) · /new to restart`, "error"); + } + } + for (const event of S.bridge.take()) absorb(event); + } + + wipe(...PALETTE.background); + + // Header: who, which piece, which engine, and whether it is busy. + const top = 2; + let x = PAD; + if (S.handle) { + ink(...PALETTE.handle); + write("@" + S.handle, { x, y: top, size: 1, font: FONT }); + x += (S.handle.length + 2) * CW; + } + ink(...PALETTE.highlight); + write(S.slug, { x, y: top, size: 1, font: FONT }); + if (S.pieceDirty) { + ink(...PALETTE.soft); + write("· tab runs it", { x: x + (S.slug.length + 1) * CW, y: top, size: 1, font: FONT }); + } + + const offline = wifi && !wifi.connected; + const statusWord = offline && S.status === "ready" ? "offline" : S.status; + const dot = S.status === "thinking" ? (paintCount % 40 < 20 ? PALETTE.highlight : PALETTE.soft) + : S.status === "ready" ? (offline ? PALETTE.highlight : PALETTE.status) + : S.status === "starting" ? PALETTE.soft + : PALETTE.error; + const modelShort = S.backend === "ac" ? `aesthetic ${acModelLabel(S.model)}` : S.model.replace(/^claude-/, ""); + const right = `${modelShort} ${statusWord}`; + const rx = W - PAD - right.length * CW; + ink(...PALETTE.muted); + write(modelShort, { x: rx, y: top, size: 1, font: FONT }); + ink(...dot); + write(statusWord, { x: rx + (modelShort.length + 1) * CW, y: top, size: 1, font: FONT }); + + // Prompt block at the bottom; an approval takes it over. + const promptH = CH + 4; + const promptY = H - promptH; + const cols = Math.max(10, Math.floor((W - PAD * 2) / CW)); + + if (S.pending) { + ink(...PALETTE.highlight); + box(0, promptY, W, promptH); + ink(...PALETTE.text); + const verb = S.pending.kind === "file" ? "write" : "run"; + const keys = " y once · a always · n no"; + const room = cols - verb.length - 1 - keys.length; + const label = S.pending.label.length > room ? S.pending.label.slice(0, Math.max(0, room - 1)) + "…" : S.pending.label; + write(`${verb} ${label}${keys}`, { x: PAD, y: promptY + 2, size: 1, font: FONT }); + } else { + ink(...PALETTE.prompt); + box(0, promptY, W, promptH); + ink(...PALETTE.text); + // Keep the cursor in view when the input outgrows the row. + const visible = cols - 1; + let start = 0; + if (S.cursor > visible) start = S.cursor - visible; + const shown = S.input.slice(start, start + visible); + write(shown, { x: PAD, y: promptY + 2, size: 1, font: FONT }); + if (paintCount % 60 < 30) { + const cx = PAD + (S.cursor - start) * CW; + ink(...PALETTE.text); + box(cx, promptY + 2, CW, CH); + const under = S.input[S.cursor]; + if (under) { ink(...PALETTE.prompt); write(under, { x: cx, y: promptY + 2, size: 1, font: FONT }); } + } + } + + // Transcript between header and prompt, bottom-aligned, scrolled back by S.scroll lines. + const bodyTop = top + CH + 4; + const bodyBottom = promptY - 2; + const rows = Math.max(1, Math.floor((bodyBottom - bodyTop) / CH)); + const lines = layout(cols); + const maxScroll = Math.max(0, lines.length - rows); + if (S.scroll > maxScroll) S.scroll = maxScroll; + const end = lines.length - S.scroll; + const begin = Math.max(0, end - rows); + let y = bodyBottom - (end - begin) * CH; + for (let i = begin; i < end; i++) { + const l = lines[i]; + if (l.mark) { + ink(...l.mark); + write(">", { x: PAD, y, size: 1, font: FONT }); + ink(...l.rgb); + write(l.text.slice(2), { x: PAD + 2 * CW, y, size: 1, font: FONT }); + } else { + ink(...l.rgb); + write(l.text, { x: PAD, y, size: 1, font: FONT }); + } + y += CH; + } + if (S.scroll > 0) { + ink(...PALETTE.muted); + const tag = `↓ ${S.scroll}`; + write(tag, { x: W - PAD - tag.length * CW, y: bodyBottom - CH, size: 1, font: FONT }); + } +} + +function leave() { + // The bridge child stays alive on purpose: leaving for the prompt or to run + // the piece is not the end of the conversation. /quit ends it. +} + +export { boot, act, paint, leave }; diff --git a/fedac/native/pieces/prompt.mjs b/fedac/native/pieces/prompt.mjs index f7eccc9b1d..55e3ef944c 100644 --- a/fedac/native/pieces/prompt.mjs +++ b/fedac/native/pieces/prompt.mjs @@ -40,6 +40,7 @@ const PIECE_DESC = { "os": "system update (OTA)", "wifi": "network picker", "claude": "AI assistant", + "aesel": "Claude writes a piece; tab runs it", "terminal": "PTY terminal", "geo": "geolocation", "chat": "real-time chat", diff --git a/fedac/native/scripts/build-and-flash.sh b/fedac/native/scripts/build-and-flash.sh index 372b6f85e0..7bef38f4f9 100755 --- a/fedac/native/scripts/build-and-flash.sh +++ b/fedac/native/scripts/build-and-flash.sh @@ -196,6 +196,9 @@ fi # The baked default score is the CultureHub piece, Note(s)pat(ial) Native. mkdir -p "${INITRAMFS_DIR}/lib" "${INITRAMFS_DIR}/scores" cp "${NATIVE_DIR}/lib/spatial-rehearsal.mjs" "${INITRAMFS_DIR}/lib/" +# Aesel's Claude bridge translator (pieces/aesel.mjs imports it from /lib). +cp "${NATIVE_DIR}/lib/aesel-bridge.mjs" "${INITRAMFS_DIR}/lib/" +cp "${NATIVE_DIR}/lib/aesel-ac.mjs" "${INITRAMFS_DIR}/lib/" cp "${NATIVE_DIR}/scores/"*.nsscore "${INITRAMFS_DIR}/scores/" cp "${NATIVE_DIR}/scores/notespatial-native.nsscore" "${INITRAMFS_DIR}/pieces/spatial-rehearsal.nsscore" diff --git a/fedac/native/scripts/flash-mac.sh b/fedac/native/scripts/flash-mac.sh index af568ab3b4..737fc8a088 100755 --- a/fedac/native/scripts/flash-mac.sh +++ b/fedac/native/scripts/flash-mac.sh @@ -461,10 +461,13 @@ log "Writing ACBOOT (kernel-direct boot tree)…" mkdir -p "${M1}/EFI/BOOT" cp "${KERNEL}" "${M1}/EFI/BOOT/BOOTX64.EFI" cp "${INITRAMFS}" "${M1}/initramfs.cpio.gz" -# Boot piece: notepat by default. Override per-flash with AC_BOOT_PIECE -# (e.g. AC_BOOT_PIECE=babypat flash-mac.sh ...). Kernel resolves the -# name to /pieces/.mjs at boot — see ac-native.c:3853. -BOOT_PIECE="${AC_BOOT_PIECE:-notepat}" +# Boot piece: aesel by default (the Claude session that writes pieces this +# machine runs). Override per-flash with AC_BOOT_PIECE (e.g. +# AC_BOOT_PIECE=notepat flash-mac.sh ...). The kernel resolves the name to +# /pieces/.mjs at boot — see the "Boot piece from config" block in +# ac-native.c. Devices already flashed keep whatever piece their +# /mnt/config.json names. +BOOT_PIECE="${AC_BOOT_PIECE:-aesel}" # Write a device config.json. Base identity fields come from the shell vars # (set from the inscription OR the legacy API path); the boot-personalization diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c index cfeb716af4..82f814f5bf 100644 --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -4035,15 +4035,45 @@ static JSValue js_fetch_post(JSContext *ctx, JSValueConst this_val, int argc, JS fclose(hf); } - ac_log("[fetchPost] start: %s (body %ld bytes)\n", url, (long)strlen(body)); + // Fourth argument, optional: { timeout: seconds, out: "/abs/path" }. + // `out` names a response file the caller owns: curl streams into it + // unbuffered, it is kept (not read into the 8 KB result slot, not + // unlinked) when the request ends, and an HTTP error still writes its + // body there so the caller can read the server's message. That is what + // lets a piece follow a long server-sent-event stream frame by frame. + int timeout = 120; + char out_path[256] = "/tmp/ac_fetch.json"; + int keep = 0; + if (argc >= 4 && JS_IsObject(argv[3])) { + JSValue jt = JS_GetPropertyStr(ctx, argv[3], "timeout"); + if (!JS_IsUndefined(jt)) JS_ToInt32(ctx, &timeout, jt); + JS_FreeValue(ctx, jt); + JSValue jo = JS_GetPropertyStr(ctx, argv[3], "out"); + if (JS_IsString(jo)) { + const char *o = JS_ToCString(ctx, jo); + if (o && o[0] == '/' && strlen(o) < sizeof(out_path) && !strchr(o, '"') && !strchr(o, '\'')) { + snprintf(out_path, sizeof(out_path), "%s", o); + keep = 1; + } + if (o) JS_FreeCString(ctx, o); + } + JS_FreeValue(ctx, jo); + } + if (timeout < 1) timeout = 1; + if (timeout > 3600) timeout = 3600; + if (keep) unlink(out_path); + snprintf(current_rt->fetch_out, sizeof(current_rt->fetch_out), "%s", keep ? out_path : ""); + + ac_log("[fetchPost] start: %s (body %ld bytes%s)\n", url, (long)strlen(body), keep ? ", streamed" : ""); char cmd[2048]; snprintf(cmd, sizeof(cmd), - "sh -c 'curl -fsSL -X POST --retry 1 --connect-timeout 10 --max-time 120 " + "sh -c 'curl %s -X POST --retry 1 --connect-timeout 10 --max-time %d " "--cacert /etc/pki/tls/certs/ca-bundle.crt " "-K /tmp/ac_fetch_headers.txt " "-d @/tmp/ac_fetch_body.json " - "--output /tmp/ac_fetch.json \"%s\" 2>/tmp/ac_fetch_err;" - " echo $? > /tmp/ac_fetch_rc' &", url); + "--output %s \"%s\" 2>/tmp/ac_fetch_err;" + " echo $? > /tmp/ac_fetch_rc' &", + keep ? "-sSL --fail-with-body -N" : "-fsSL", timeout, out_path, url); system(cmd); current_rt->fetch_pending = 1; current_rt->fetch_result[0] = 0; @@ -4065,6 +4095,10 @@ static JSValue js_fetch_cancel(JSContext *ctx, JSValueConst this_val, int argc, unlink("/tmp/ac_fetch.json"); unlink("/tmp/ac_fetch_rc"); unlink("/tmp/ac_fetch_err"); + if (current_rt->fetch_out[0]) { + unlink(current_rt->fetch_out); + current_rt->fetch_out[0] = 0; + } current_rt->fetch_pending = 0; current_rt->fetch_result[0] = 0; current_rt->fetch_error[0] = 0; @@ -6076,7 +6110,23 @@ static JSValue js_pty_spawn(JSContext *ctx, JSValueConst this_val, int argc, JSV if (argc > 2) JS_ToInt32(ctx, &cols, argv[2]); if (argc > 3) JS_ToInt32(ctx, &rows, argv[3]); - int ok = pty_spawn(¤t_rt->pty, cols, rows, cmd, child_argv); + // Fifth argument: {raw, cwd} (or a bare boolean for raw). See pty_spawn_ex. + int raw = 0; + const char *cwd = NULL; + if (argc > 4) { + if (JS_IsObject(argv[4])) { + JSValue jr = JS_GetPropertyStr(ctx, argv[4], "raw"); + raw = JS_ToBool(ctx, jr) > 0; + JS_FreeValue(ctx, jr); + JSValue jc = JS_GetPropertyStr(ctx, argv[4], "cwd"); + if (JS_IsString(jc)) cwd = JS_ToCString(ctx, jc); + JS_FreeValue(ctx, jc); + } else { + raw = JS_ToBool(ctx, argv[4]) > 0; + } + } + int ok = pty_spawn_ex(¤t_rt->pty, cols, rows, cmd, child_argv, raw, cwd); + if (cwd) JS_FreeCString(ctx, cwd); // Free ToCString results for args for (int i = 1; i < nargs; i++) { @@ -6153,7 +6203,23 @@ static JSValue js_pty2_spawn(JSContext *ctx, JSValueConst this_val, int argc, JS int cols = 80, rows = 24; if (argc > 2) JS_ToInt32(ctx, &cols, argv[2]); if (argc > 3) JS_ToInt32(ctx, &rows, argv[3]); - int ok = pty_spawn(¤t_rt->pty2, cols, rows, cmd, child_argv); + // Fifth argument: {raw, cwd} (or a bare boolean for raw). See pty_spawn_ex. + int raw = 0; + const char *cwd = NULL; + if (argc > 4) { + if (JS_IsObject(argv[4])) { + JSValue jr = JS_GetPropertyStr(ctx, argv[4], "raw"); + raw = JS_ToBool(ctx, jr) > 0; + JS_FreeValue(ctx, jr); + JSValue jc = JS_GetPropertyStr(ctx, argv[4], "cwd"); + if (JS_IsString(jc)) cwd = JS_ToCString(ctx, jc); + JS_FreeValue(ctx, jc); + } else { + raw = JS_ToBool(ctx, argv[4]) > 0; + } + } + int ok = pty_spawn_ex(¤t_rt->pty2, cols, rows, cmd, child_argv, raw, cwd); + if (cwd) JS_FreeCString(ctx, cwd); for (int i = 1; i < nargs; i++) JS_FreeCString(ctx, child_argv[i]); JS_FreeCString(ctx, cmd); if (ok == 0) { current_rt->pty2_active = 1; return JS_TRUE; } @@ -7290,7 +7356,15 @@ static JSValue build_system_obj(JSContext *ctx) { ac_log("[fetch] done: curl exit=%d\n", code); current_rt->fetch_result[0] = 0; current_rt->fetch_error[0] = 0; - if (code == 0) { + int streamed = current_rt->fetch_out[0] != 0; + if (streamed) { + // The caller reads its own file; the result slot carries the path. + snprintf(current_rt->fetch_result, sizeof(current_rt->fetch_result), "%s", current_rt->fetch_out); + current_rt->fetch_out[0] = 0; + } + if (streamed && code == 0) { + unlink("/tmp/ac_fetch_err"); + } else if (code == 0) { FILE *fp = fopen("/tmp/ac_fetch.json", "r"); if (fp) { int n = (int)fread(current_rt->fetch_result, @@ -7736,6 +7810,24 @@ static JSValue build_system_obj(JSContext *ctx) { current_rt->pty_active = 0; } + if (current_rt->pty.raw) { + // Raw line mode: hand the piece every whole line that arrived + // since the last frame. The grid below is never touched. + static char raw_line[PTY_RAW_BUF]; + JSValue lines = JS_NewArray(ctx); + uint32_t n = 0; + int len; + // Only the paint build hands lines out: act and sim rebuild this + // object too, and a line drained there would never be seen. + if (strcmp(current_phase, "paint") == 0) + while ((len = pty_next_line(¤t_rt->pty, raw_line, (int)sizeof(raw_line))) >= 0) + JS_SetPropertyUint32(ctx, lines, n++, JS_NewStringLen(ctx, raw_line, len)); + JS_SetPropertyStr(ctx, pty_obj, "raw", JS_TRUE); + JS_SetPropertyStr(ctx, pty_obj, "lines", lines); + JS_SetPropertyStr(ctx, pty_obj, "overflow", JS_NewBool(ctx, current_rt->pty.raw_overflow)); + current_rt->pty.raw_overflow = 0; + } + JS_SetPropertyStr(ctx, pty_obj, "alive", JS_NewBool(ctx, current_rt->pty.alive)); JS_SetPropertyStr(ctx, pty_obj, "cursorX", @@ -7811,6 +7903,24 @@ static JSValue build_system_obj(JSContext *ctx) { current_rt->pty2_active = 0; } + if (current_rt->pty2.raw) { + // Raw line mode: hand the piece every whole line that arrived + // since the last frame. The grid below is never touched. + static char raw_line[PTY_RAW_BUF]; + JSValue lines = JS_NewArray(ctx); + uint32_t n = 0; + int len; + // Only the paint build hands lines out: act and sim rebuild this + // object too, and a line drained there would never be seen. + if (strcmp(current_phase, "paint") == 0) + while ((len = pty_next_line(¤t_rt->pty2, raw_line, (int)sizeof(raw_line))) >= 0) + JS_SetPropertyUint32(ctx, lines, n++, JS_NewStringLen(ctx, raw_line, len)); + JS_SetPropertyStr(ctx, pty2_obj, "raw", JS_TRUE); + JS_SetPropertyStr(ctx, pty2_obj, "lines", lines); + JS_SetPropertyStr(ctx, pty2_obj, "overflow", JS_NewBool(ctx, current_rt->pty2.raw_overflow)); + current_rt->pty2.raw_overflow = 0; + } + JS_SetPropertyStr(ctx, pty2_obj, "alive", JS_NewBool(ctx, current_rt->pty2.alive)); JS_SetPropertyStr(ctx, pty2_obj, "cursorX", diff --git a/fedac/native/src/js-bindings.h b/fedac/native/src/js-bindings.h index 17ea08757d..a89de4eaf0 100644 --- a/fedac/native/src/js-bindings.h +++ b/fedac/native/src/js-bindings.h @@ -43,6 +43,7 @@ typedef struct { // Async HTTP fetch state (curl in background) int fetch_pending; // 1 = waiting for curl char fetch_result[8192]; // JSON response when done, empty otherwise + char fetch_out[256]; // fetchPost {out}: caller-owned response file, kept on completion char fetch_error[256]; // one-shot fetch error string // Binary fetch for OS update (curl -L -o destPath url) diff --git a/fedac/native/src/pty.c b/fedac/native/src/pty.c index 427e6c1b3b..294a4aa6cb 100644 --- a/fedac/native/src/pty.c +++ b/fedac/native/src/pty.c @@ -12,6 +12,7 @@ #include #include #include +#include extern void ac_log(const char *fmt, ...); extern int ac_log_stderr_muted; @@ -490,7 +491,13 @@ static void process_byte(ACPty *pty, uint8_t b) { } int pty_spawn(ACPty *pty, int cols, int rows, const char *cmd, char *const argv[]) { + return pty_spawn_ex(pty, cols, rows, cmd, argv, 0, NULL); +} + +int pty_spawn_ex(ACPty *pty, int cols, int rows, const char *cmd, char *const argv[], + int raw, const char *cwd) { memset(pty, 0, sizeof(*pty)); + pty->raw = raw ? 1 : 0; pty->cols = (cols > 0 && cols <= PTY_MAX_COLS) ? cols : 80; pty->rows = (rows > 0 && rows <= PTY_MAX_ROWS) ? rows : 24; pty->cur_fg = PTY_COLOR_DEFAULT_FG; @@ -768,6 +775,23 @@ int pty_spawn(ACPty *pty, int cols, int rows, const char *cmd, char *const argv[ fclose(tf); } } + if (raw) { + // Headless bridge: the parent parses JSON lines, so nothing it + // writes may echo back, nothing may gain a carriage return on the + // way out, and no input line may be cut at the canonical limit. + struct termios tio; + if (tcgetattr(STDIN_FILENO, &tio) == 0) { + cfmakeraw(&tio); + tcsetattr(STDIN_FILENO, TCSANOW, &tio); + } + setenv("TERM", "dumb", 1); + setenv("NO_COLOR", "1", 1); + } + if (cwd && cwd[0]) { + mkdir(cwd, 0755); + if (chdir(cwd) != 0) + fprintf(stderr, "[pty-child] chdir '%s' failed: %s\r\n", cwd, strerror(errno)); + } execvp(cmd, argv); // exec failed — write error to stderr (flows through PTY to parent) int err = errno; @@ -809,24 +833,69 @@ int pty_pump(ACPty *pty) { uint8_t buf[4096]; int total = 0; + int budget = pty->raw ? PTY_RAW_BUF : 32768; // don't block too long per frame for (;;) { + if (pty->raw && pty->raw_len >= PTY_RAW_BUF) break; // consumer must drain first ssize_t n = read(pty->master_fd, buf, sizeof(buf)); if (n <= 0) break; - for (ssize_t i = 0; i < n; i++) { - process_byte(pty, buf[i]); + if (pty->raw) { + int room = PTY_RAW_BUF - pty->raw_len; + int take = (int)n < room ? (int)n : room; + memcpy(pty->raw_buf + pty->raw_len, buf, take); + pty->raw_len += take; + if (take < (int)n) pty->raw_overflow = 1; + } else { + for (ssize_t i = 0; i < n; i++) { + process_byte(pty, buf[i]); + } } total += (int)n; - if (total > 32768) break; // don't block too long per frame + if (total > budget) break; } return total; } +int pty_next_line(ACPty *pty, char *out, int max) { + if (!pty->raw || pty->raw_len <= 0 || max <= 1) return -1; + char *nl = memchr(pty->raw_buf, '\n', pty->raw_len); + if (!nl) { + if (pty->raw_len >= PTY_RAW_BUF) { + // One line longer than the whole buffer: nothing sane to hand out. + pty->raw_len = 0; + pty->raw_overflow = 1; + } + return -1; + } + int len = (int)(nl - pty->raw_buf); + int copy = len < max - 1 ? len : max - 1; + memcpy(out, pty->raw_buf, copy); + out[copy] = '\0'; + int rest = pty->raw_len - (len + 1); + if (rest > 0) memmove(pty->raw_buf, nl + 1, rest); + pty->raw_len = rest; + return copy; +} + int pty_write(ACPty *pty, const char *data, int len) { if (pty->master_fd < 0 || !data || len <= 0) return -1; - ssize_t written = write(pty->master_fd, data, len); - return (int)written; + // The master is non-blocking and the line discipline's input queue is + // only a few kilobytes, so one write() can land short. A bridge line (a + // tool approval echoing a whole file) must arrive intact, so keep going + // while the child is draining, and give up only when it has stopped. + int done = 0, waits = 0; + while (done < len) { + ssize_t w = write(pty->master_fd, data + done, len - done); + if (w > 0) { done += (int)w; continue; } + if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) && waits++ < 5) { + struct pollfd pfd = { .fd = pty->master_fd, .events = POLLOUT }; + if (poll(&pfd, 1, 200) > 0) continue; + } + break; + } + if (done < len) ac_log("[pty] short write: %d of %d bytes\n", done, len); + return done; } int pty_resize(ACPty *pty, int cols, int rows) { diff --git a/fedac/native/src/pty.h b/fedac/native/src/pty.h index f43dd85ea6..3ffb41774f 100644 --- a/fedac/native/src/pty.h +++ b/fedac/native/src/pty.h @@ -8,6 +8,11 @@ #define PTY_MAX_COLS 256 #define PTY_MAX_ROWS 128 +// Raw line mode buffer. A headless bridge (claude --print --output-format +// stream-json) talks in JSON lines; a single tool result can carry a whole +// file, so the buffer is sized for a few of those between frames. +#define PTY_RAW_BUF (256 * 1024) + // ANSI color palette (standard 16 colors) #define PTY_COLOR_DEFAULT_FG 7 // white #define PTY_COLOR_DEFAULT_BG 0 // black @@ -60,12 +65,30 @@ typedef struct { // Saved cursor position (for ESC 7 / ESC 8) int saved_x, saved_y; uint8_t saved_fg, saved_bg, saved_bold; + + // Raw line mode: bytes bypass the terminal emulator and accumulate here; + // pty_next_line() hands them out one whole line at a time. + int raw; // 1 = raw line mode (grid is never touched) + int raw_len; // bytes waiting in raw_buf + int raw_overflow; // 1 = bytes were dropped since the last read + char raw_buf[PTY_RAW_BUF]; } ACPty; // Create a PTY and spawn a command (e.g., "claude") // Returns 0 on success, -1 on error int pty_spawn(ACPty *pty, int cols, int rows, const char *cmd, char *const argv[]); +// The same, with options. raw=1 puts the slave in raw mode (no echo of what +// the parent writes, no CRLF translation, no canonical line-length cap) and +// buffers output as lines instead of driving the grid. cwd, when set, is +// created and made the child's working directory after the standard setup. +int pty_spawn_ex(ACPty *pty, int cols, int rows, const char *cmd, char *const argv[], + int raw, const char *cwd); + +// Raw mode: copy the next complete line (without its newline) into out. +// Returns its length, or -1 when no whole line is buffered yet. +int pty_next_line(ACPty *pty, char *out, int max); + // Read available output from the PTY and update the grid // Non-blocking. Returns number of bytes processed. int pty_pump(ACPty *pty); -- 2.51.2