diff --git a/.claude/agent-brief.md b/.claude/agent-brief.md new file mode 100644 index 0000000..ee26092 --- /dev/null +++ b/.claude/agent-brief.md @@ -0,0 +1,579 @@ +# Goal: Hermes Agent and Claude Code both hold a real conversation in the Agent container + +## THE ARCHITECTURE (user-set, 2026-08-14): Hermes runs INSIDE Mouse; Mouse is its tool surface + +The user's words: "We should think of the mouse app as a scoped tool/skill/mcp +exposed for the hermes agent running inside of mouse." And, before that: "I +never, ever, ever told you to run hermes on my mac. I told you, embed hermes +into the application." Both are binding. No gateway on the Mac, no external +service. The Mac is the build host, nothing else. + +Measured facts the design must live with (on-device CPython wasi — 3.12.0 since the zlib swap): + + import ssl FAILS import sqlite3 FAILS + import asyncio ok import zipfile ok + pip / ensurepip ABSENT compiled extensions: wasi CPython cannot + dlopen, so pydantic-core / cryptography / + psutil can never load, ever + +Why the MCP framing is not just preference but the ONLY shape that fits: the +agent loop (prompt assembly, tool dispatch, response parsing) is pure Python and +runs fine on wasi. Everything wasi CANNOT do — TLS, processes, the filesystem — +Mouse already does natively: URLSession, msh, the workspace. Hermes is built for +exactly this split: it already abstracts shell backends (local/Docker/SSH) and +speaks MCP to external tools. Mouse becomes one more backend — the phone. + +Build order: +1. **Wheel installer** (`pip install `): PyPI JSON API + + wheels are zips + `Runtimes.swift` already has the zip reader. Registers a + site-packages dir the wasm Python imports from. Gate it on installing a real + pure wheel (e.g. `python-dotenv`) and importing it on device. +2. **The bridge — files per step, not resident stdio.** Measured constraints: + `wasi.start` is SYNCHRONOUS (the engine's JS thread is blocked while Python + runs, so nothing async can answer it mid-run) and `fd_read` on stdin answers + 0 bytes — instant EOF. So a resident MCP-over-stdio process is not possible + on today's engine. What is: ONE PYTHON INVOCATION PER AGENT STEP. + - Swift writes `bridge/turn.json` (conversation so far + tool results). + - Python runs the loop step, exits having written `bridge/out.json`: + either `{"answer": …}` or `{"tool": "llm.complete"|"shell"|"read_file"|…, + "args": …}`. + - Swift executes the tool natively — model calls on URLSession (real TLS), + shell on msh, files on the workspace — appends the result, reruns Python. + State lives in files between steps, which is also how Hermes already + persists sessions. Cold-start per step is the price (~1–3s, measured on the + pip probes); a resident process is an optimization for after the engine + grows blocking stdin, not a prerequisite. +3. **Hermes profile for Mouse** — MEASURED now, not predicted. `pip install + hermes-agent==0.19.0` lands EIGHT packages clean, hermes-agent's own 9.9 MB + pure wheel included, plus openai 3.0.0, httpx, rich, tenacity, fire, dotenv, + certifi. The closure stops at pyyaml (no pure wheel published). On device: + - `import openai` dies on `import zlib` — the wasi CPython BUILD ships no + zlib module. Engine-side gap, not a packaging one: candidates are a + different CPython wasi artifact that includes zlib, or a shim. + - The yaml wall FELL: pip substitutes pyyaml with ruamel.yaml (a PyYAML + fork, pure, already in hermes's pins) plus a `yaml.py` adapter of the + PyYAML surface. `yaml.safe_load` works on device; hermes's utils.py + imports through its yaml lines including the SafeDumper subclass. + - `Path.home()` needed a HOME: Runtimes.json sets `HOME=/`, so agent + state (`~/.hermes`) lands in the WORKSPACE — per-project agent state, + which fits Mouse. + - The "hang" was a CRAWL: -X importtime on the live path showed imports + streaming at hundreds of ms each. The chain then died at real lines, + each now answered: `import ssl` (auth.py:26) → a pure ssl shim that + imports clean and refuses at use; `import webbrowser` → shimmed, there + is no browser on that side; `concurrent.futures.thread` (the build + omits it; wasi has no threads) → an INLINE executor via sitecustomize. + - **`import run_agent` SUCCEEDS on the device.** Warnings only: a plugin + fails on hashlib.scrypt (this build's hashlib lacks it), and the + futures patch logs an unknown-location note. Warm import ≈ 4 minutes + with the pyc cache on ({root}/pycache — PYTHONDONTWRITEBYTECODE is + gone); cold ≈ 6. Startup cost is now the biggest UX problem, ahead of + any correctness one. + - NEXT, designed — RECORD-AND-REPLAY through `AIAgent.chat`. The loop's + front door is `AIAgent(base_url=…, api_key=…, model=…).chat(message) + -> str` (run_agent.py:5295), synchronous. Its transport is the openai + SDK, which cannot import here (pydantic-core), and wasi Python has no + sockets regardless — so the step driver installs a FAKE `openai` + module in sys.modules before importing hermes. The fake's + chat.completions.create(): + 1. consults the recorded responses in turn.json, returning them in + order for calls 1..n-1 (cheap objects with .choices[0].message); + 2. on the first UNRECORDED call, raises a Capture carrying the + request; the driver writes it as {"tool": "llm.complete"} and + exits; Mouse executes it on URLSession and reruns with the + response appended. + Each step deterministically replays prior turns without network — the + price is re-running python logic per step, on top of the ≈4 minute + warm import, which makes the STARTUP COST the thing to solve next: + likely one resident python invocation per CONVERSATION (not per step) + once the engine grows blocking stdin, or import pruning. + Walls known ahead from the dependency list: pydantic-core (via pydantic, + compiled Rust) and psutil (compiled C). pydantic imports lazily in the + openai SDK's typed paths — how far the loop gets without it is a + measurement, not a guess. + +Verify each stage on the simulator; a stage that only works with something +running on the Mac fails the user's constraint by definition. + +## Stop condition + +Both agents, in the container, in a chat interface, actually working: + +1. Pick **Claude Code** → type a prompt → its answer appears as an agent message. +2. Pick **Hermes Agent** → type a prompt → its answer appears as an agent message. + +Both verified on the simulator by driving the app, with a screenshot of each +answering. Not "it installed", not "it printed something" — an answer to a +question, on screen, in the exchange. + +Setup is allowed and expected. The current Hermes has **savable profiles**, so +whatever configuration a first run needs (model, API key, backend) is saved and +does not have to be redone every launch. Build that setup into the container +rather than requiring the user to go to the Terminal container. + +## THE ANSWER, from the docs — an OpenAI-compatible endpoint + +`/docs/user-guide/features/api-server`. Everything needed to build the client: + +- **Start it:** `hermes gateway`. The API server listens on + **`http://127.0.0.1:8642`** by default — loopback, so a real phone needs the + host bound wider or reached over the LAN; the SIMULATOR shares the Mac's + network stack and can use 127.0.0.1 directly. +- **Endpoint:** `POST /v1/chat/completions`, plain OpenAI shape: + + {"model": "hermes-agent", + "messages": [{"role": "user", "content": "…"}], + "stream": false} + + answering with `choices[0].message.content`. `"stream": true` gives SSE with + token chunks plus `hermes.tool.progress` events for tool visibility — the + streamed narration the orb was built for. `GET /health` is a cheap reachability + check and `GET /v1/models` names the profile. +- **Auth:** `Authorization: Bearer `, REQUIRED for every + deployment including the default loopback bind. It cannot be disabled. The key + is a static value the user sets in the env / profile `.env`. +- **Model name:** defaults to the profile name, or `hermes-agent` for the default + profile. +- **THE PROFILES the user meant:** multi-profile routing gives each profile its + own `API_SERVER_KEY` in its own `.env`. So a saved setup here is a (base URL, + key, model) triple per profile, and the container's settings should hold that + shape rather than a single string. + +This is an ordinary HTTP client — no socket, no framing, no WebSocket. It also +generalises: anything OpenAI-shaped could be another entry in the catalog. + +### What to do with `HermesGateway` +Delete the transport. The `Event` type is close to what an SSE stream yields and +may survive; `connect`/`write`/`receive`/`readLine` and the whole TCP path do +not, and neither does `verify/hermesgateway`'s stub. Replacing them with a +URLSession POST is smaller than what is being removed. + +## Superseded — the TUI gateway reading + +From the official docs (hermes-agent.nousresearch.com/docs/user-guide/messaging), +which should have been read before any of this was built: + +- `hermes gateway` is the MESSAGING gateway, and it works by **polling platform + APIs outbound** — Telegram, Discord, Slack, Signal, Matrix, ntfy and a long + list of others. It exposes no inbound endpoint. +- **There is no generic or custom channel.** Only named, pre-built platforms. + So "be a front-end like Telegram is" is not available: Telegram works because + Hermes has Telegram-specific code and polls Telegram's servers. +- The docs name a separate **"Open WebUI + API Server"** integration. That is + the supported way a custom client talks to Hermes, and it is almost certainly + an OpenAI-shaped chat-completions endpoint, which this app can speak trivially. + +NEXT: fetch the Open WebUI / API Server integration page for its exact path, +port, payload and auth, then point the container at that. Do not build any more +transport before reading it. + +`tui_gateway` was the wrong target twice over: it is stdio with a WebSocket +dashboard face, and it is an internal detail rather than a documented interface. +`HermesGateway`'s framing and `Event` type may still be reusable; its transport +almost certainly is not. + +## Superseded twice — the TCP client and the WebSocket plan + +`HermesGateway` speaks newline-delimited JSON over **TCP**, and it is proven +against a stub — streamed events, split writes, advancing ids, refused +addresses, and a real conversation rendered in the container. But `tui_gateway` +does not listen on TCP. `tui_gateway/server.py` drives the agent over a child +process's **stdin/stdout** (`proc.stdin.write(json.dumps({"id", "command"}) + +"\n")`), and its network face is the **WebSocket** layer in `tui_gateway/ws.py`, +served by uvicorn, which is what the dashboard attaches to. + +So the line protocol is right and the socket is wrong. Two ways to close it, and +the first is the honest one: + +1. **Speak the WebSocket layer.** Hermes already serves it for a non-terminal + front-end, which is the same argument that makes the Telegram bot work. + `HermesGateway` keeps its framing and its `Event`; only the transport under + `connect`/`write`/`receive` changes. Read `tui_gateway/ws.py` for the URL + shape and whatever handshake it expects. +2. A stdio-to-TCP bridge on the host — fewer changes here, but it asks the user + to run a shim, which is a worse product than talking to what Hermes serves. + +The TCP path stays useful either way: it is what the stub gate exercises, and it +is the fallback for anyone who does run a bridge. + +## Superseded — the original gateway client design + +The container asks for `HERMES_GATEWAY host:port` and then refuses to use it, +because Hermes is marked blocked for having no local install. That is +incoherent: the address is exactly what makes it NOT blocked. `blocked` should +be conditional — no gateway configured means unusable, a configured gateway +means usable — and `send()` should take a different path entirely for it: + +- Claude Code: run the CLI locally, as now. +- Hermes: open a socket to `HERMES_GATEWAY`, write `{"id": n, "command": …}` as + one line, read event lines back, map them onto messages. No install, no + launch, no terminal. `tui_gateway/server.py` (`dispatch`, `write_json`) is the + protocol and `hermes_cli/telegram_managed_bot.py` is a working front-end to + copy the shape from. + +Verify it against a STUB that speaks the protocol before asking the user to run +the real thing — a fake gateway on the Mac proves the client without needing +their Python environment or their keys. + +## Where it actually stands (measured, not assumed) + +The container renders and the picker works. Neither agent runs. Sending "Hello" +with Hermes selected produced, on the user's own device: + + Hello + pip install hermes-agent ← the install note + (no output) ← the agent message + +That was three separate faults, and all three are now found and fixed: + +- **There is no pip.** `pkg install python` lands CPython 3.14.6 and that build + answers `python -m pip --version` with "No module named pip" and `ensurepip` + the same. No Python package can be installed on this device. Hermes is + therefore a network client or nothing. +- **The reporting was lying.** `run` waited only on `isRunning`, which a + full-screen program leaves false, and counted an error line as success. Both + fixed; it now shows the command's own words. +- **Scoped bins were never registered.** `npm i -g @anthropic-ai/claude-code` + said "added 1 packages" and left no `claude`, because the top-level test read + "no slash after node_modules/" and `@scope/name` has one. Fixed, gated in + `verify/scopedbin`. `claude` now resolves and starts — and then holds the + terminal as a program with no output, which is the auth wall below. + +## CLAUDE CODE: CLOSED — 1.0.128 is broken everywhere, and our engine matched real node + +The full chain, measured to the end: + + statsig.anthropic.com NXDOMAIN — the host is GONE + bare fetch to it through our engine fails fast, cleanly, releases + claude 1.0.128 + key, our engine silent forever (statsig retry loop) + claude 1.0.128 + key, REAL node v22, the Mac killed at 40s, zero output + +The pinned version awaits statsig initialisation before its first print, the +statsig host no longer exists anywhere, and its client retries without limit. +NOT an engine bug — the engine reproduced real node exactly, including the +hang. Two of my earlier eliminations were also watchdog lies (an uncondition- +al echo after sleep reads as a kill); the interrupt ledger is the tool that +cut through. + +THE VERSION HUNT, measured: the JS line runs to at least 2.1.98 (cli.js in +the tarball; the 24 KB installer stubs start by 2.1.232). On real node, +2.1.98 with a key answers in 2 seconds. On our engine it hangs — and this one +IS ours, with a sharper shape than any before: + + interrupt at 40s → only the ^C echo. No ledger report, no busy report. + +The loop never reached its own cancelled-check: ONE SYNCHRONOUS JS JOB runs +forever, and cancellation is only observed between jobs. Nothing is pending; +the thread is spinning inside a single job (an Atomics.wait polyfill or a +Date.now() spin are the classic shapes). + +NEXT TOOL: a job watchdog on JSC's execution-time-limit +(JSContextGroupSetExecutionTimeLimit): when one job exceeds N seconds, +terminate it and surface the JS stack of the termination — that names the +spin site in the 9 MB bundle directly. Then decide whether the spun-on +primitive is implementable or the call site is patchable. + +Also true: 2.1.98 on real node used the MAC'S OAuth login despite an env key +being set — on device there is no such fallback, so a real key in the field +remains the auth story once the spin is fixed. + +## SIGN-IN: claude's own prompts, inside the chat container + +The user goes through Claude Code's normal first-run auth in the container: +either the ANTHROPIC_API_KEY field, or a `sign in` row that runs +`claude setup-token` — claude's documented OAuth flow — on an embedded +terminal screen where the exchange normally sits. MEASURED on the simulator: +the ink UI renders (art, OAuth URL, "Paste code here if prompted >"), an +`open claude.com` chip reassembles the hard-wrapped URL and opens Safari on +Anthropic's real login page, the input field feeds the program (a bogus code +came back "OAuth error: Invalid code … Press Enter to retry", and Enter +retried with a fresh URL), and a `stop` chip takes the terminal back in one +tap — setup-token swallows ^C as a keystroke, so the chip uses the hard stop, +not the two-press ritual. A finished sign-in lands +`.claude/.credentials.json` in the SHARED home: the Agent container exports +HOME=/home (RuntimeStore.home on disk, mounted by the shell alongside /usr), +and `os.homedir()` now honors $HOME the way real node does. Sign in once, +every project has it — cwd stays the workspace, so the agent still works on +the ring's project. MEASURED: a sign-in run wrote .claude/.claude.json only +to MouseHome; the workspace copies kept yesterday's timestamps. +`AgentSession.authenticated` accepts that file or the saved key, and both +auth rows hide. Only the final step — a real Anthropic account authorizing — +remains for the user; everything around it is verified. + +## CLAUDE CODE: DONE — 2.1.98 answers in the container on the simulator + +The whole path, measured end to end (Aug 14): pick Claude Code in the +container → `npm i -g @anthropic-ai/claude-code@2.1.98` runs on the app's own +npm → `claude -p ''` runs on the engine → it POSTs +`/v1/messages?beta=true` with `stream: true` → parses the SSE events → +prints the answer → the container renders it as an agent message. Screenshot +taken with an Anthropic-shaped stand-in at 127.0.0.1:8699 (SSE: +message_start / content_block_delta / message_stop — the non-stream JSON +shape makes it exit(1) on `K.input_tokens`, so the stand-in must stream). +With a real key and no address the same path hits api.anthropic.com. + +What it took, in order: scoped bins registered (`verify/scopedbin`), the five +engine fixes below, the spawnSync guard relaxed to refuse only `input`, the +catalog pin moved 1.0.128 → 2.1.98 (1.0.128 is dead upstream — statsig +NXDOMAIN), and ANTHROPIC_BASE_URL exported when the container's address field +is set (`endpointVariable` in the catalog). Setup fields commit on blur as +well as return, and AgentSettings reads subscribe views via `version` — a +saved key/address now hides its field immediately. + +## Superseded — where 2.1.98 stood after the five fixes (transpiler guard, +## stream/consumers, stream/web, Symbol.dispose, rename(2)) + +`--version` prints; `-p 'hi'` runs to completion, exits rc=0, prints NOTHING, +and sends NOTHING (a stand-in base_url logged no request). Its own session +record says `"kind":"interactive"` for a `-p` run — it never took print mode. +argv reaches programs intact (`argv=-p|hi|--output-format|json` measured), so +the flag arrives and something later rejects the mode. `--print 'hi'` HANGS +instead of exiting mute — a real divergence from `-p`, unexplained. + +SOLVED ONE LAYER DOWN: the telemetry files named `tengu_unhandled_rejection` +on every run, a first-registered listener caught the real object, and it was +OUR OWN spawnSync guard — the engine REFUSED the `timeout` option on +principle, claude 2.x probes ripgrep with spawnSync{timeout}, and the throw +became the silent startup death. The guard now refuses only `input` (the one +genuinely unimplementable option); an ignored timeout on an +already-completed synchronous run is the truth, not a lie. + +STILL MUTE after that fix: `-p` no longer dies of the rejection but produces +no output and no API request; under require() the module loads and returns +without running main. Next diagnostic: the in-place cli.js instrumentation +pattern works (prepend hooks under a MOUSE_HOOK marker) — extend it to log +the promise chain around its main() entry, or diff what main-detection reads +(process.argv[1] vs import.meta) between real node and the engine. The +telemetry-reading recipe (JSONL in 1p_failed_events.*, event_data.event_name) +is the fastest signal for each new layer. + +## Superseded — the statsig stream as an engine bug + +The interrupt ledger answered on its second use: + + interrupted while waiting on: 9 timers, 1× http stream to statsig.anthropic.com + +With a key, 1.0.128 initialises statsig (feature flags/telemetry) BEFORE its +first output, and that streaming request never completes on our engine — held +past 127 seconds when URLRequest's default timeout is 60, so delegate events +for that session are not being delivered at all. That is the engine bug to fix +next: reproduce with a bare httpStream to statsig.anthropic.com, find why +didCompleteWithError never fires (large/gzip body? redirect? the response +never draining?), and fix the transport. DISABLE_TELEMETRY / +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC did not bypass it in 1.0.128. + +Where the tool lives: `outstanding` is now a LABELED ledger (`hold(_)` / +`release(_)`), and an interrupted run reports what it was waiting on — named +hosts for http streams. Any future "it just hangs" starts by pressing ^C and +reading the answer. + +## Superseded — nine eliminations reached by hand + +Everything below is measured, not reasoned: + + dead base_url (http://127.0.0.1:9) hangs the same — NOT the network target + net.connect to a refused port error event in 1ms — sockets fine + execSync('security …') screenless throws instantly — no bridge deadlock + the grid during the hang empty, onScreen=false — no hidden TUI + ~/.claude.json onboarding seeded read correctly (homedir=/), still hangs + fetch / https.request / streaming / exit / startup / stdin — all previously green + +With a key, `claude -p` awaits SOMETHING that never resolves, before its first +write, regardless of endpoint, with every external ruled out. Blind bisection +of a 9.4 MB minified bundle is the wrong tool. The right one is engine-side: +a diagnostic that answers "what is this process waiting ON" — pending timers, +sockets, unresolved host calls — dumped when a run is interrupted. That is a +real engine feature (the streaming probe needed the same question answered), +and it turns this class of bug from guesswork into a lookup. + +## Superseded — the single-suspect framing + +Not the compound. `export FOO=bar && echo compound-ok` returns in 0s, so `&&` +is fine and the earlier note blaming it was wrong. What actually correlates is +whether a key is set: + + claude -p 'say hi' 3s "Invalid API key · Please run /login" + export ANTHROPIC_API_KEY=sk-ant-invalid + claude -p 'say hi' 63s still running, nothing printed + +Two SEPARATE commands on one session — exactly how `AgentSession` does it. With +no key the CLI short-circuits at its own validation and prints. With a key it +gets past validation and makes a real HTTPS call to the API, and THAT is where +it stops. So the suspect is the engine's network path under whatever HTTP client +claude-code 1.0.128 uses, not the shell and not the launch path. + +THIS WILL HIT A VALID KEY TOO. A real key also gets past validation into the +same request. Do not tell the user Claude Code is one key away again until this +is understood. + +RULED OUT: the network. Both clients reach the real API and come back fast, +with the same request shape the CLI would send: + + fetch → 0.2s, 401, {"type":"authentication_error", …} + https.request → 0.2s, 401, same body + +So HTTPS, TLS, DNS and the response path all work under the engine, and whatever +1.0.128 does after passing its own key validation, it is not a plain request to +api.anthropic.com that stalls. + +RULED OUT TOO: startup, and stdin. With the key exported, in the same session +that then hangs: + + claude --version 0s 1.0.128 (Claude Code) + claude --help 0s full usage text + +So the CLI loads, parses, reads its config and prints — none of that waits on a +screen, a prompt or a stdin that never arrives. The hang is specific to `-p` +actually making its request. + +Which leaves ONE suspect, and it fits every measurement: `claude -p` asks for a +STREAMING response. A plain request/response works (0.2s, 401, both clients); +what has not been tested is reading a body that arrives in chunks over time. +With no key the CLI never gets that far — it fails validation and prints in 3s. +With a key it opens the stream, and that is exactly where it stops. + +TESTED. Reading a chunked body with real delays works exactly right: + + headers at 0.0s status=200 + chunk 1 at 0.0s … chunk 4 at 1.2s + reader DONE at 1.6s after 4 chunks + +and the process exits on its own afterwards. Both forms do: + + text() exited after 2s + getReader() exited after 1s + +An earlier note here claimed the process never exited and built a whole theory +on it. That was MY HARNESS lying: the watchdog printed "KILLED" unconditionally +after its sleep, whether or not the process was still alive. The probe had +already finished. Streaming is fine, exiting is fine, and no theory should be +built on a message a test prints regardless of outcome. + +SO THE CLAUDE `-p` HANG IS STILL UNEXPLAINED. Eliminated by measurement so far: +the missing key, the launch path (real bug, fixed, not this), the `&&` compound, +the network (fetch and https.request both 0.2s to the real API), startup +(`--version` and `--help` instant WITH a key set), stdin, and now streaming and +event-loop exit. + +MEASURED SINCE: it emits NOTHING while hung. On the live path — where a program +writes into the transcript as it goes, so partial output is visible — `claude +--debug -p 'say hi'` produced not one line in 24 seconds. `--debug` should be +noisy from the first moment. So it blocks BEFORE its first write, which rules +out anything that happens after the CLI starts doing visible work, and makes an +early await the suspect: something in its startup path that only runs when a key +is present. + +Note the measurement trick, since it took three iterations to find: the +screenless path returns output only when the run COMPLETES, so it shows nothing +about a hang. Run the same command WITHOUT `screenless` and poll +`session.lines` — a program emits into the transcript live. + +What has NOT been looked at: what the CLI does between passing validation and +issuing its request. It writes state — `~/.claude`-style config, onboarding +flags, a project trust record. A write to a path the workspace filesystem +handles differently, or a lock/retry around one, would fit: instant without a +key because validation short-circuits first, slow with one because that path is +only reached when the key looks usable. Instrument the engine's fs calls during +the hang and see what it touches last. +(A probe artefact to avoid repeating: a `setTimeout` left running keeps the +engine's loop alive after the work resolves, which looks like a hang and is not.) + +## Superseded — the launch path (fixed, and it was real) + +The experiment below settled it. Piping anything into the command defeats +`if interactive, stdin.isEmpty` in `runNode`, which sends it down the path that +RETURNS output instead of handing it to `launchProgram` as a screen-owning +program: + + echo '' | claude -p 'say hi' 3s + Invalid API key · Please run /login + +Three seconds, and a real answer from the real CLI. It starts fine on this +engine, reaches its auth check and reports it in words. Every hang was our +launch path: `runInstalledBin` passes `interactive: true`, the bin becomes a +`NodeProgram` that owns the terminal, and a print-mode invocation that wants to +write and exit sits there forever. + +THE FIX belongs in how the agent is invoked, not in a pipe trick. `-p` is a +non-interactive invocation and should be dispatched as one. Options, best first: + 1. Let `TerminalSession.run` take a non-interactive flag that `AgentSession` + sets, threading through to `runNode`'s `interactive:`. + 2. Decide interactivity from the command — a bin invoked with `-p`/`--print` + is not a screen program. Narrower, and guesses at CLI conventions. +Do NOT ship `echo '' | …` as the mechanism; it works by accident of the +stdin test and would confuse the next reader. + +One loose end: `export ANTHROPIC_API_KEY=… && echo '' | claude -p …` hung for +63s where the same pipeline without the `export &&` returned in 3. Something +about the compound puts it back on the interactive path — worth understanding, +because `AgentSession` exports before it launches. + +## Superseded — "does not hang for want of a key" + +Measured, so nobody spends a key finding out: + + npm install -g @anthropic-ai/claude-code@1.0.128 added 1 packages / bin: claude + export ANTHROPIC_API_KEY=sk-ant-invalid-for-testing && claude -p 'say hi' + 42s, still running, nothing printed + +An invalid key should be REJECTED, quickly and in words. Instead the CLI takes +the terminal as a full-screen program and never comes back, exactly as it did +with no key at all. So authentication is not the wall — something in 1.0.128's +startup does not complete on this engine, and a real key will not change it. + +Where to look next: msh runs an installed bin through `runInstalledBin` with +`interactive: true`, which hands it to `context.launchProgram` as a `NodeProgram` +that owns the screen and returns immediately. `-p` is supposed to be the +non-interactive mode, so either the CLI is not taking that path, or it is +waiting on a stdin/TTY that never delivers. Run it NON-interactively — the same +call with `interactive: false` goes through `engine.run` and returns output — +and compare. That one experiment separates "our launch path is wrong" from +"the CLI cannot start here". + +## The one thing only the user can supply + +- **A running `hermes gateway`** and its profile's `API_SERVER_KEY`. The client, + the key field and the address field are all built and gated; nothing else is + needed for Hermes to answer. + +Claude Code needs no key from anyone until the hang above is understood. + +## What is already known — do not re-derive + +- **Hermes is a TUI, and its gateway is the way in.** `tui_gateway/` in + `~/Projects/hermes-agent` is how Hermes talks to front-ends that are not a + terminal; the Telegram bot is one of them. `python -m tui_gateway.entry` + speaks **newline-delimited JSON over stdio** — `{"id": …, "command": …}` in, + events out — with a WebSocket sidecar for dashboards. Read + `tui_gateway/server.py` (`dispatch`, `write_json`) and + `hermes_cli/telegram_managed_bot.py` for how a chat front-end maps onto it. + **The container does not speak this yet. That is the main missing piece.** +- **Claude Code is pinned at 1.0.128** and must stay pinned: current releases + ship `bin/claude.exe`, a native binary iOS cannot execute. 1.0.128 is the last + JS-all-the-way-down line and STATUS.md records it running on the phone. +- **Oh My Pi is out** — Bun CLI with Rust native bindings, no path on iOS. +- Nothing is bundled; each agent installs with its own published command. + +## Testing the app — the parts that cost time last run + +- **Edge swipe skips onboarding.** Swipe in from the right edge starting around + x=393 (further in than 4pt, or iOS claims it for Control Center). Do this + first after every install instead of walking the four lessons. +- A reinstall resets onboarding every time, so expect to do it every iteration. +- The ring cycles GitHub → Files → Viewer → Graph → Terminal → **Agent**: five + left swipes from GitHub. Horizontal swipe from (340,450) to (60,450). +- The Agent container is **kind 16**, deliberately not 6 (6–15 were retired + placeholders and live in old snapshots). +- Pick a project first — the Files container's header opens the picker. With no + project the session refuses and says so. +- Build to a **clean derivedDataPath** and check the product before trusting it: + a stale `/tmp` product wasted an iteration. `strings` on this binary finds + nothing — check `Info.plist` keys instead. +- Run `cd swift && xcodegen generate` after adding any file, or the build + compiles without it and the error is "cannot find X in scope". + +## Rules + +Fix it in `swift/`. Verify on the simulator every iteration. Commit at each +boundary with the evidence. No demo scaffolding, no leftover diagnostics, docs +move with behaviour. diff --git a/STATUS.md b/STATUS.md index 4ada9c4..24834f6 100644 --- a/STATUS.md +++ b/STATUS.md @@ -27,7 +27,7 @@ numbers some of the same ground differently; the mapping is noted per row. | **D** — web toolchain | tsc, bundlers, dev servers | **Largely done**, as a byproduct of G | tsc + `tsc --watch`, webpack, esbuild-wasm, vite dev (HMR) and vite build (rollup-wasm) all gated (`verify/esbuild`, `devserver`, `hmr`, `firstrun`). A real SvelteKit project runs `npm run dev` on the device: starts once, pre-bundles its 20 dependencies, and answers a curl from the Mac with HTTP 200 and the rendered 33 kB page. Remaining piece is the Preview surface (phase C) | | **B** — WebView JIT | Move JS/wasm execution into WKWebView for JIT speed | **Not started — optional** | Measured: everything runs interpreted; the JIT buys speed, not capability (system.md:2094). No longer a prerequisite for anything | | **C** — Preview container | In-app viewing surface for what dev servers serve; LAN hosting | **Not started** | The server half works (vite serves clients outside the app — gated in `verify/devserver`); no in-app viewer exists | -| **E** — wasm runtime processes | Real processes: `$PATH`, executable bits, `ps`/`kill`/`&`, pipes between programs; other languages (Python first) as wasm32-wasi artifacts | **Runtime half done** | `pkg install python` downloads the official CPython wasm32-wasi build, hash-checks it, unpacks it (zip reader written here — iOS has no `unzip`) and `python hello.py` runs CPython 3.14.6. `swift/Mouse/Runtimes.swift` + mounts in `NodeEngine`. Gated: `verify/python`, `verify/pkgpython`. Written up in system.md §5b. Missing: `$PATH`, executable bits, background jobs (`&` is still refused by name in the lexer) | +| **E** — wasm runtime processes | Real processes: `$PATH`, executable bits, `ps`/`kill`/`&`, pipes between programs; other languages (Python first) as wasm32-wasi artifacts | **Runtime half done** | `pkg install python` downloads a CPython wasm32-wasi build (VMware Labs' 3.12.0, chosen because it compiles zlib in — the official 3.14 build does not, and no zlib kills `import openai` before any agent code runs), hash-checks it, unpacks it and `python hello.py` runs it. `swift/Mouse/Runtimes.swift` + mounts in `NodeEngine`. Gated: `verify/python`, `verify/pkgpython`. Written up in system.md §5b. Missing: `$PATH`, executable bits, background jobs (`&` is still refused by name in the lexer) | ## On the device @@ -51,8 +51,8 @@ launches and is driven on the iPhone 16 Pro simulator an opinion about how it prints was printing its raw fields instead. Gated in `verify/inspectopts` and `verify/nodeprint`. - **Python runs on the phone.** `pkg install python` prints `fetching - python 3.14.6 (14 MB)` / `installed python 3.14.6`; `python -c` prints - `python 3.14.6 on wasi` and `{"squares": [0, 1, 4, 9, 16, 25]}`; and + python … ` / `installed python`; `python -c` prints + its version `on wasi` and `{"squares": [0, 1, 4, 9, 16, 25]}`; and `python hello.py` prints what the script prints. Screenshots at 23:48 on 2026-07-31. - **A node server runs on the phone and answers real requests from off the @@ -70,17 +70,35 @@ launches and is driven on the iPhone 16 Pro simulator only while a program runs; tapping it sends the interrupt the program already knew how to handle. Verified: the server stopped, the prompt came back, and the port stopped answering. -- **An agent CLI starts and renders its UI on the phone.** claude-code - 1.0.128 installs through our own npm, reports `1.0.128 (Claude Code)`, - and its React/ink TUI draws its bordered `Welcome to Claude Code` frame - on the phase-T screen. Screenshot at 23:58. +- **An agent CLI answers a prompt on the phone.** claude-code 2.1.98 + installs through our own npm, runs `-p` to completion on the engine, and + its answer renders in the Agent container's exchange on the simulator + (screenshot Aug 14, against an Anthropic-shaped stand-in; a real key and + an empty address point the same path at api.anthropic.com). The 1.0.x + line that first rendered its TUI here is dead UPSTREAM — it awaits + statsig.anthropic.com, which no longer resolves — and hangs identically + on real node. 2.1.98 is the newest release that is JavaScript the whole + way down. +- **Claude Code's own sign-in runs inside the Agent container.** The `sign + in` row hosts `claude setup-token` — its real ink screen — on an embedded + terminal grid: the OAuth URL renders, an `open claude.com` chip + reassembles it from the wrapped rows and opens Safari on Anthropic's + login page, the chat input feeds the program (bogus code → claude's own + "OAuth error: Invalid code", Enter → fresh retry), and `stop` reclaims + the terminal in one tap. A finished sign-in stores claude's credential in + the SHARED home — the container exports HOME=/home, a mount every shell + carries, so one sign-in covers every project while cwd stays the ring's + workspace — and both auth rows (sign-in and ANTHROPIC_API_KEY) disappear. + Verified on the simulator Aug 15: a sign-in run wrote its config only to + the shared home; the per-workspace copies kept the previous day's + timestamps. - **claude-code's CURRENT releases cannot run here, and that is a change in the package, not a regression in the engine.** `@anthropic-ai/claude-code` now ships `bin/claude.exe` — a per-platform NATIVE binary — with `cli-wrapper.cjs` as a fallback that spawns it. iOS will not execute unsigned native code, so this is the platform wall the wasm strategy - exists for, reached from a new direction. The JS-bundle versions (1.0.128 - and its era) still run. Any claim here about "claude-code" means those. + exists for, reached from a new direction. The JS-bundle versions (through + 2.1.9x) still run. Any claim here about "claude-code" means those. - **Interactive TUIs work.** `npx create-vite` walks its whole flow on the phone: text prompt, framework menu, variant menu, install confirmation — every transition painting live, colours intact, selections tracking. diff --git a/swift/Mouse.xcodeproj/project.pbxproj b/swift/Mouse.xcodeproj/project.pbxproj index 17d489e..cc3d0d4 100644 --- a/swift/Mouse.xcodeproj/project.pbxproj +++ b/swift/Mouse.xcodeproj/project.pbxproj @@ -12,11 +12,13 @@ 13494FDD63F9B72CF5ADE9C0 /* GitRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75575D9A3B5DE3BF7A0B2C0 /* GitRemote.swift */; }; 2023624DA13C04BD2E402AF1 /* NodeScrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D9E4CB580ECCF3AC62E780 /* NodeScrypt.swift */; }; 20348E0EC1CA68374BEB45D0 /* NodeEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6C7D80DCC2D8B70348B7B6E /* NodeEngine.swift */; }; + 228B8E59CDB542455422113B /* Dictation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1954B09B2BF0B9E3B5AE309D /* Dictation.swift */; }; 26A30C412C7EDEC903FAB619 /* NodeDNS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933C6240D3EEE403A1818213 /* NodeDNS.swift */; }; 2835B0449AF44C49E2FDA13B /* AsciiLogoBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB3CB2A63A95F423462137F /* AsciiLogoBackground.swift */; }; 2999E7E33AFEDC980ED858F8 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00872DE418EF1E21957DA2B3 /* TerminalSession.swift */; }; 2E7F41B7E5FCEFF2ECC7AA09 /* NodeBrotli.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA007916E1B80807354B7C21 /* NodeBrotli.swift */; }; 2F0EE6F9105610221954933F /* TerminalWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95DA74F4F94E389C0F12E31 /* TerminalWidth.swift */; }; + 347909752F3F2A92DC40B57A /* AgentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D6E033D6CB86160958F3E35 /* AgentContainerView.swift */; }; 356A7B9FE48709698EEEF0FA /* AsciiArtLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21FD3889B912E90E5C1C09BF /* AsciiArtLabel.swift */; }; 3762A096CA885FBEA52CC4AD /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B23A8EB8050D0EFED1198A4 /* Workspace.swift */; }; 3850D15015A8389199DB6D3F /* AsciiArtStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0114DFFF7C492C6DE1FE952 /* AsciiArtStyle.swift */; }; @@ -33,33 +35,44 @@ 5FBF4EB2A75F6E489D12C014 /* GitHubPush.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE14386F75AAF0B835E324C /* GitHubPush.swift */; }; 6E38F06822105BFE7C367992 /* GitCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179217813E4A44F9859669C5 /* GitCore.swift */; }; 7200308360108CD7B01CC6A9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2B92464EFB49888F46248A5 /* Assets.xcassets */; }; + 772F0D445CEB88040FF63AE2 /* ThinkingOrb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0193B1BE4E33E64176275AE0 /* ThinkingOrb.swift */; }; 8AD0462F79AD667DA80205E2 /* AsciiArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E73E50FC2D809F76902185C /* AsciiArt.swift */; }; 8F0C87F3E43FA31B4B65A73F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503560AF5D76D20B38402E7F /* ContentView.swift */; }; 95EECE607920E991570AF1F7 /* ShellLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080B55B70E27E9BD21FDF728 /* ShellLanguage.swift */; }; + 9E72C9E2C2391E14B0137C7D /* AgentAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D212CFEB96B91B39300A9B6B /* AgentAPI.swift */; }; A0BF04BC9035D20B399D75B7 /* Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A7FDD7DE14447F992EB32D3 /* Shell.swift */; }; + A52CEF1173F42F8DC093484F /* AgentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAF2A8E2ACEBCC2AD01EF2D /* AgentSession.swift */; }; A7CA8073A0F4F3C57BDAD7A7 /* Terminal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1234209A0254A06BB8E915 /* Terminal.swift */; }; AE73F32B4C2FD5EF9A76994C /* GitGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B299975B30C229DCCCB015 /* GitGraphView.swift */; }; B12D6FC121DECB1E6754DA64 /* NodeSockets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EAFE39A6EE12DDA5EA8D3F /* NodeSockets.swift */; }; + B49F9E6B29A297122BCFDF37 /* AgentCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 119D8305D7BBD8F65808AE6A /* AgentCatalog.swift */; }; B61F67FB754D9C300BB0C97D /* Runtimes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A5E9B8DC023465151C2E32 /* Runtimes.swift */; }; CBFE9B07C55D2D88C76AE7EF /* AppFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5808E2C3F3EE351EB16E5DD /* AppFont.swift */; }; + D855B4B114C514E0BD93F235 /* AgentSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7BC5BA08EEADE47C5B986F /* AgentSettings.swift */; }; DC525882F3784C97FF273D57 /* IBMPlexMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 98C60EC69E3F855F129D64D0 /* IBMPlexMono-Bold.ttf */; }; DD34307A47D2C84BD6C63C19 /* WorkspaceViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA49DFD4B52682285B51D396 /* WorkspaceViews.swift */; }; + F027CE6A4DE2E85D3551C7A9 /* PipInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC0D465DCBBF6AD11F92C29D /* PipInstaller.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 00872DE418EF1E21957DA2B3 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = ""; }; + 0193B1BE4E33E64176275AE0 /* ThinkingOrb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThinkingOrb.swift; sourceTree = ""; }; 03A5E9B8DC023465151C2E32 /* Runtimes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Runtimes.swift; sourceTree = ""; }; 03EAFE39A6EE12DDA5EA8D3F /* NodeSockets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeSockets.swift; sourceTree = ""; }; 05D6F771DF19B303F2B844F2 /* Mouse.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Mouse.app; sourceTree = BUILT_PRODUCTS_DIR; }; 080B55B70E27E9BD21FDF728 /* ShellLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellLanguage.swift; sourceTree = ""; }; 0D7D43BF3F50D2D3AAC7E907 /* NodeWatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeWatch.swift; sourceTree = ""; }; 0E73E50FC2D809F76902185C /* AsciiArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsciiArt.swift; sourceTree = ""; }; + 119D8305D7BBD8F65808AE6A /* AgentCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentCatalog.swift; sourceTree = ""; }; 179217813E4A44F9859669C5 /* GitCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitCore.swift; sourceTree = ""; }; 18D9E4CB580ECCF3AC62E780 /* NodeScrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeScrypt.swift; sourceTree = ""; }; + 1954B09B2BF0B9E3B5AE309D /* Dictation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictation.swift; sourceTree = ""; }; + 1CAF2A8E2ACEBCC2AD01EF2D /* AgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSession.swift; sourceTree = ""; }; 21FD3889B912E90E5C1C09BF /* AsciiArtLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsciiArtLabel.swift; sourceTree = ""; }; 2D1234209A0254A06BB8E915 /* Terminal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Terminal.swift; sourceTree = ""; }; 38F529B8B560B4DB034F9698 /* MouseApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MouseApp.swift; sourceTree = ""; }; 3A88D15C6B606882BE9F472E /* PackageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageManager.swift; sourceTree = ""; }; + 3D6E033D6CB86160958F3E35 /* AgentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentContainerView.swift; sourceTree = ""; }; 503560AF5D76D20B38402E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 525AB48D09A783EF07137189 /* TerminalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScreen.swift; sourceTree = ""; }; 5A7FDD7DE14447F992EB32D3 /* Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shell.swift; sourceTree = ""; }; @@ -67,6 +80,7 @@ 5CB3CB2A63A95F423462137F /* AsciiLogoBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsciiLogoBackground.swift; sourceTree = ""; }; 7D58EFCD024FB4FFA0E73B4D /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; 8EE8007D9DA67ECAD904DE3B /* ForegroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundView.swift; sourceTree = ""; }; + 8F7BC5BA08EEADE47C5B986F /* AgentSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSettings.swift; sourceTree = ""; }; 933C6240D3EEE403A1818213 /* NodeDNS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDNS.swift; sourceTree = ""; }; 98C60EC69E3F855F129D64D0 /* IBMPlexMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "IBMPlexMono-Bold.ttf"; sourceTree = ""; }; 9FCD3BEDD150B2840E5F82B0 /* TerminalPrograms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPrograms.swift; sourceTree = ""; }; @@ -81,17 +95,24 @@ C698F23621B02ACFD773CEFC /* Runtimes.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Runtimes.json; sourceTree = ""; }; C6C7D80DCC2D8B70348B7B6E /* NodeEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeEngine.swift; sourceTree = ""; }; CAE14386F75AAF0B835E324C /* GitHubPush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubPush.swift; sourceTree = ""; }; + D212CFEB96B91B39300A9B6B /* AgentAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentAPI.swift; sourceTree = ""; }; E2B92464EFB49888F46248A5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; EA007916E1B80807354B7C21 /* NodeBrotli.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeBrotli.swift; sourceTree = ""; }; F0800EDDCC9396803E8C7B94 /* StripPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripPersistence.swift; sourceTree = ""; }; F5B299975B30C229DCCCB015 /* GitGraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitGraphView.swift; sourceTree = ""; }; FB01C138F6E8F2292E1D3EC4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + FC0D465DCBBF6AD11F92C29D /* PipInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipInstaller.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ 3F51774E2273014186C920CC /* Mouse */ = { isa = PBXGroup; children = ( + D212CFEB96B91B39300A9B6B /* AgentAPI.swift */, + 119D8305D7BBD8F65808AE6A /* AgentCatalog.swift */, + 3D6E033D6CB86160958F3E35 /* AgentContainerView.swift */, + 1CAF2A8E2ACEBCC2AD01EF2D /* AgentSession.swift */, + 8F7BC5BA08EEADE47C5B986F /* AgentSettings.swift */, C5808E2C3F3EE351EB16E5DD /* AppFont.swift */, 7D58EFCD024FB4FFA0E73B4D /* AppSettings.swift */, 0E73E50FC2D809F76902185C /* AsciiArt.swift */, @@ -101,6 +122,7 @@ E2B92464EFB49888F46248A5 /* Assets.xcassets */, A61DF9311DFBF5412D0FE960 /* CarouselDeck.swift */, 503560AF5D76D20B38402E7F /* ContentView.swift */, + 1954B09B2BF0B9E3B5AE309D /* Dictation.swift */, 8EE8007D9DA67ECAD904DE3B /* ForegroundView.swift */, 179217813E4A44F9859669C5 /* GitCore.swift */, F5B299975B30C229DCCCB015 /* GitGraphView.swift */, @@ -117,6 +139,7 @@ 03EAFE39A6EE12DDA5EA8D3F /* NodeSockets.swift */, 0D7D43BF3F50D2D3AAC7E907 /* NodeWatch.swift */, 3A88D15C6B606882BE9F472E /* PackageManager.swift */, + FC0D465DCBBF6AD11F92C29D /* PipInstaller.swift */, 03A5E9B8DC023465151C2E32 /* Runtimes.swift */, 5A7FDD7DE14447F992EB32D3 /* Shell.swift */, 080B55B70E27E9BD21FDF728 /* ShellLanguage.swift */, @@ -126,6 +149,7 @@ 525AB48D09A783EF07137189 /* TerminalScreen.swift */, 00872DE418EF1E21957DA2B3 /* TerminalSession.swift */, B95DA74F4F94E389C0F12E31 /* TerminalWidth.swift */, + 0193B1BE4E33E64176275AE0 /* ThinkingOrb.swift */, 5B23A8EB8050D0EFED1198A4 /* Workspace.swift */, BA49DFD4B52682285B51D396 /* WorkspaceViews.swift */, 70539524C5912AC66F3C3EA3 /* Fonts */, @@ -227,6 +251,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9E72C9E2C2391E14B0137C7D /* AgentAPI.swift in Sources */, + B49F9E6B29A297122BCFDF37 /* AgentCatalog.swift in Sources */, + 347909752F3F2A92DC40B57A /* AgentContainerView.swift in Sources */, + A52CEF1173F42F8DC093484F /* AgentSession.swift in Sources */, + D855B4B114C514E0BD93F235 /* AgentSettings.swift in Sources */, CBFE9B07C55D2D88C76AE7EF /* AppFont.swift in Sources */, 5430CDE9E255DBF12E04A8E5 /* AppSettings.swift in Sources */, 8AD0462F79AD667DA80205E2 /* AsciiArt.swift in Sources */, @@ -235,6 +264,7 @@ 2835B0449AF44C49E2FDA13B /* AsciiLogoBackground.swift in Sources */, 5F08C64F05CA4FF19DDA911F /* CarouselDeck.swift in Sources */, 8F0C87F3E43FA31B4B65A73F /* ContentView.swift in Sources */, + 228B8E59CDB542455422113B /* Dictation.swift in Sources */, 0A6F9CDD9D58550755EB4B28 /* ForegroundView.swift in Sources */, 6E38F06822105BFE7C367992 /* GitCore.swift in Sources */, AE73F32B4C2FD5EF9A76994C /* GitGraphView.swift in Sources */, @@ -250,6 +280,7 @@ B12D6FC121DECB1E6754DA64 /* NodeSockets.swift in Sources */, 4AE107D17AAB32363E162AA3 /* NodeWatch.swift in Sources */, 4F544F15A1B0E044B5AEDDC0 /* PackageManager.swift in Sources */, + F027CE6A4DE2E85D3551C7A9 /* PipInstaller.swift in Sources */, B61F67FB754D9C300BB0C97D /* Runtimes.swift in Sources */, A0BF04BC9035D20B399D75B7 /* Shell.swift in Sources */, 95EECE607920E991570AF1F7 /* ShellLanguage.swift in Sources */, @@ -259,6 +290,7 @@ 5300617D815A8D12CAD0FD96 /* TerminalScreen.swift in Sources */, 2999E7E33AFEDC980ED858F8 /* TerminalSession.swift in Sources */, 2F0EE6F9105610221954933F /* TerminalWidth.swift in Sources */, + 772F0D445CEB88040FF63AE2 /* ThinkingOrb.swift in Sources */, 3762A096CA885FBEA52CC4AD /* Workspace.swift in Sources */, DD34307A47D2C84BD6C63C19 /* WorkspaceViews.swift in Sources */, ); diff --git a/swift/Mouse/AgentAPI.swift b/swift/Mouse/AgentAPI.swift new file mode 100644 index 0000000..fcce71f --- /dev/null +++ b/swift/Mouse/AgentAPI.swift @@ -0,0 +1,85 @@ +import Foundation + +/// A client for Hermes Agent's API server — and for anything else OpenAI-shaped. +/// +/// `hermes gateway` serves `POST /v1/chat/completions` on `http://127.0.0.1:8642`, taking +/// `{"model", "messages", "stream"}` and answering in `choices[0].message.content`, with +/// `Authorization: Bearer ` required on every deployment including the loopback +/// bind. That is the documented way a custom client talks to Hermes. The TUI gateway this file +/// used to speak to is an internal detail, and the messaging gateway only polls named platforms +/// outward, so neither was ever an interface for this app to call. +/// +/// Being OpenAI-shaped, none of this is Hermes-specific: any agent serving that endpoint is one +/// catalog entry away. +struct AgentAPI: Sendable { + /// Where the server is. `hermes gateway` binds loopback, which the SIMULATOR can reach + /// because it shares the Mac's network stack — a real phone needs the server bound wider or + /// reached across the LAN. + let baseURL: URL + /// `API_SERVER_KEY`. Not optional: Hermes requires bearer auth on every deployment and will + /// not let it be disabled, so a missing key is a configuration error, not an anonymous call. + let key: String + /// Defaults to the profile name, or `hermes-agent` for the default profile. + let model: String + + /// `host:port` as typed into the container, with the documented default filled in. + init?(address: String, key: String, model: String = "hermes-agent") { + let trimmed = address.trimmingCharacters(in: .whitespaces) + let text = trimmed.isEmpty ? "127.0.0.1:8642" : trimmed + let withScheme = text.contains("://") ? text : "http://" + text + guard let url = URL(string: withScheme), url.host != nil else { return nil } + baseURL = url + self.key = key + self.model = model + } + + enum Failure: Error, CustomStringConvertible { + case http(Int, String) + case malformed(String) + case unreachable(String) + + var description: String { + switch self { + // 401 is the common one and is its own explanation: the key is wrong or absent. + case .http(let code, let body): + return "the agent answered \(code)" + (body.isEmpty ? "" : ": \(body)") + case .malformed(let what): return "the agent's answer made no sense: \(what)" + case .unreachable(let why): return "cannot reach the agent: \(why)" + } + } + } + + /// One turn. The whole conversation goes up each time, which is what the endpoint expects — + /// it is stateless per request, like every OpenAI-shaped API. + func complete(_ conversation: [(role: String, content: String)]) async throws -> String { + var request = URLRequest(url: baseURL.appendingPathComponent("v1/chat/completions")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 180 + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "model": model, + "messages": conversation.map { ["role": $0.role, "content": $0.content] }, + "stream": false, + ]) + + let data: Data, response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw Failure.unreachable(error.localizedDescription) + } + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw Failure.http(http.statusCode, + String(decoding: data.prefix(300), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines)) + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let message = choices.first?["message"] as? [String: Any], + let content = message["content"] as? String else { + throw Failure.malformed(String(decoding: data.prefix(300), as: UTF8.self)) + } + return content + } +} diff --git a/swift/Mouse/AgentCatalog.swift b/swift/Mouse/AgentCatalog.swift new file mode 100644 index 0000000..b77b252 --- /dev/null +++ b/swift/Mouse/AgentCatalog.swift @@ -0,0 +1,147 @@ +import Foundation + +/// The coding agents the Agent container can drive. +/// +/// Nothing here is bundled. Each entry carries the agent's OWN install command, verbatim from +/// its documentation, and the container runs it through the same msh + npm + Node the terminal +/// uses — the acceptance test system.md sets for this app. The catalog is the part that ships: +/// which agents exist, what each needs, and how to start it. +/// +/// Oh My Pi (`omp`) is deliberately absent. It is a Bun CLI with Rust native bindings, and Bun +/// is a native binary — iOS will not execute one, the same wall that stops opencode's Go TUI and +/// current claude-code's `claude.exe`. Listing it as choosable would be a lie; it returns when +/// there is a wasm Bun or an `omp` server this app can speak to. +struct CodingAgent: Identifiable, Sendable, Hashable { + let id: String + /// What the picker shows. + let name: String + /// The runtime it needs, named the way the user would install it (`pkg install …`). + let runtime: Runtime + /// Its own install command, exactly as its documentation gives it. + let install: String + /// The command that starts an interactive session once installed. + let launch: String + /// The executable the install is expected to leave behind, used to answer "is it here yet". + let executable: String + /// The env var that redirects this agent's endpoint when the address field is set, for + /// CLI agents. nil when the agent has no such override, or is embedded (the embedded path + /// uses the address directly). + let endpointVariable: String? + + /// The agent's OWN sign-in command — a full-screen program the container hosts on an + /// embedded terminal screen, so authenticating works the way the agent documents it + /// instead of the way a settings form imagines it. nil when a key is the only way in. + let login: String? + + /// Whether this agent runs EMBEDDED: its loop as Python steps on the device's own wasi + /// CPython, with Mouse executing every tool the loop asks for. The alternative is a local + /// CLI on the Node layer (Claude Code). + let embedded: Bool + + /// The one thing this agent needs before it can answer, saved between launches. + let setting: Setting? + + struct Setting: Sendable, Hashable { + /// The environment variable, or the settings key — the agent's own name for it. + let name: String + /// What the field asks for. Short: it sits under a text field, not in a manual. + let placeholder: String + /// Keychain rather than UserDefaults. + let secret: Bool + /// Exported into the shell before the agent runs. + let exported: Bool + } + + /// Set when the agent cannot work on this device today. The picker shows the entry and the + /// reason rather than hiding it — a missing choice reads as an oversight. + let blocked: String? + + enum Runtime: String, Sendable { + case node + case python + + /// The `pkg` name that provides it, or nil when the app already carries it. + var packageName: String? { + switch self { + case .node: return nil // the Node layer is the app + case .python: return "python" // CPython wasm32-wasi, downloaded on demand + } + } + } + + static let all: [CodingAgent] = [claudeCode, hermes] + + /// Claude Code — Node, so it runs on the layer this app already is. + /// + /// Pinned deliberately, and MOVED: 1.0.128 died everywhere in 2026 (it awaits statsig + /// initialisation and statsig.anthropic.com is NXDOMAIN now), and the installer-stub era + /// that ships `claude.exe` — a native binary iOS can never run — starts by 2.1.232. The + /// 2.1.9x line is the newest that is JavaScript the whole way down, and 2.1.98 is measured + /// answering through this engine's streaming pipeline in one second. + static let claudeCode = CodingAgent( + id: "claude-code", + name: "Claude Code", + runtime: .node, + install: "npm i -g @anthropic-ai/claude-code@2.1.98", + launch: "claude", + executable: "claude", + // ANTHROPIC_BASE_URL, when the address field is set: any Anthropic-shaped endpoint — + // a relay, a proxy, a test double — and empty means the real API. + endpointVariable: "ANTHROPIC_BASE_URL", + // `setup-token` is Claude Code's documented sign-in: it renders its own screen, + // prints the OAuth URL, takes the pasted code, and stores a long-lived credential in + // the SHARED home (/home) — sign in once, every project has it. MEASURED rendering + // and prompting on this engine. + login: "claude setup-token", + embedded: false, + // The other way in. Either this key or a completed sign-in satisfies the container. + setting: Setting(name: "ANTHROPIC_API_KEY", placeholder: "sk-ant-…", + secret: true, exported: true), + blocked: nil + ) + + /// Hermes Agent — Python, on the CPython wasm build `pkg install python` fetches. + /// + /// Its own README offers local, Docker and SSH shell backends, which is why it fits here at + /// all: the local backend wants `fork`/`exec` that iOS does not have, and the remote ones are + /// already network-shaped. + static let hermes = CodingAgent( + id: "hermes", + name: "Hermes Agent", + runtime: .python, + install: "pip install hermes-agent==0.19.0", + // Hermes is a TUI, and a TUI is the gap this container does not host. It does not need + // one: `tui_gateway` is how Hermes already talks to front-ends that are not a terminal — + // the Telegram bot is one — speaking newline-delimited JSON over stdio, + // `{"id": …, "command": …}` in, events out. A protocol, not a screen. + launch: "python -m tui_gateway.entry", + executable: "hermes", + endpointVariable: nil, + login: nil, + // Embedded, per the user's architecture: the loop runs on the device's Python, and + // Mouse is the scoped tool surface it drives — model calls on URLSession's real TLS + // (this Python has no ssl), shell on msh, files on the workspace. + embedded: true, + // Not a key: an address. Hermes runs on a machine and this is a client of its gateway, + // which is the shape its Telegram front-end already has. + // The key, not the address. Hermes requires bearer auth on every deployment including + // the loopback bind, and `hermes gateway` serves 127.0.0.1:8642 by default — which the + // simulator reaches — so the key is the one thing that cannot be defaulted. + // The embedded loop still needs a model. Any OpenAI-compatible endpoint works — the + // address field names it, this key authenticates it. + setting: Setting(name: "LLM_API_KEY", placeholder: "key for your model endpoint", + secret: true, exported: false), + // MEASURED, not guessed: `pkg install python` lands CPython 3.14.6, and that wasi build + // answers `python -m pip --version` with "No module named pip" and `ensurepip` with "No + // module named ensurepip". There is no way to install a Python package on this device + // today, so `pip install hermes-agent` cannot run, and hermes-agent's own native + // dependencies would be the next wall behind it. + // + // The way in is the one Telegram uses: Hermes runs on a machine, and the chat front-end + // is a CLIENT of its gateway. That is a network client this container can be, and it is + // the next thing to build here. + // Not blocked any more: with an address it works, and without one the setup field is + // what asks for it. The wall was never Hermes — it was having nowhere to send to. + blocked: nil + ) +} diff --git a/swift/Mouse/AgentContainerView.swift b/swift/Mouse/AgentContainerView.swift new file mode 100644 index 0000000..a5b0300 --- /dev/null +++ b/swift/Mouse/AgentContainerView.swift @@ -0,0 +1,389 @@ +import SwiftUI + +/// The Agent container (kind 6): a coding agent working on the ring's workspace. +/// +/// Laid out the way the reference is: the exchange scrolls above, a follow-up field with a +/// microphone sits at the bottom, and the status line under it carries the workspace and the +/// agent picker. Vertical scroll and taps only, per the gesture law — the horizontal drag +/// belongs to the shell. +struct AgentContainerView: View { + var deck: CarouselDeck? + + @State private var session = AgentSession() + @State private var dictation = Dictation() + @State private var draft = "" + @State private var pickerOpen = false + @State private var settings = AgentSettings.shared + @State private var setupDraft = "" + @State private var addressDraft = "" + @FocusState private var inputFocused: Bool + @FocusState private var addressFocused: Bool + @FocusState private var setupFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("\(session.agent.name.lowercased()) on \(deck?.workspace?.repoFullName ?? "no project")") + .font(.custom(AppFont.asciiName, size: 11)) + .opacity(0.55) + .lineLimit(1) + .truncationMode(.middle) + Color.clear.frame(height: 12) + if session.loggingIn { loginScreen } else { exchange } + Spacer(minLength: 0) + if let problem = session.problem ?? dictation.problem { + Text(problem) + .font(.custom(AppFont.asciiName, size: 11)) + .opacity(0.85) + .padding(.bottom, 6) + } + if pickerOpen { picker } + // A blocked agent does not ask for setup. Hermes needs a gateway address, but + // nothing here can use one yet, and a field that collects a value the app ignores is + // worse than no field — it reads as "configure me and I will work". + // The address asks on its own terms, not behind the key. Gating it on the key being + // empty meant it could never be reached once a key was saved — and a keychain entry + // survives deleting the app, so "reinstall to fix it" does not work either. Submit an + // address, even the default one, and the row goes. + if session.agent.embedded || session.agent.endpointVariable != nil, session.agent.blocked == nil, + !session.loggingIn, settings.address(for: session.agent).isEmpty { + addressField + } + // Two ways in, both the agent's own: its sign-in flow, or its key. Either one + // satisfies `authenticated` and both rows go. + if let setting = session.agent.setting, session.agent.blocked == nil, + !session.loggingIn, !session.authenticated { + if session.agent.login != nil { loginRow } + setup(setting) + } + if let blocked = session.agent.blocked { + Text(blocked) + .font(.custom(AppFont.asciiName, size: 10)) + .opacity(0.4) + .padding(.bottom, 8) + } + input + statusLine + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .foregroundStyle(.white) + .onAppear { session.attach(root: deck?.workspace?.root) } + .onChange(of: deck?.workspace?.root) { _, root in session.attach(root: root) } + } + + // MARK: - The exchange + + private var exchange: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(session.messages) { message in + row(message).id(message.id) + } + if session.working { + ThinkingOrbLabel(state: .working, text: "working…") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onChange(of: session.messages.count) { _, _ in + withAnimation { proxy.scrollTo(session.messages.last?.id, anchor: .bottom) } + } + } + } + + @ViewBuilder + private func row(_ message: AgentSession.Message) -> some View { + switch message.author { + case .you: + Text(message.text) + .font(.custom(AppFont.asciiName, size: 13)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + case .agent: + Text(message.text) + .font(.custom(AppFont.asciiName, size: 13)) + .textSelection(.enabled) + case .note: + Text(message.text) + .font(.custom(AppFont.asciiName, size: 10)) + .opacity(0.4) + } + } + + // MARK: - Input + + private var input: some View { + HStack(spacing: 8) { + TextField("", text: $draft, axis: .vertical) + .font(.custom(AppFont.asciiName, size: 13)) + .textFieldStyle(.plain) + .lineLimit(1...4) + .focused($inputFocused) + .submitLabel(.send) + .onSubmit(send) + // The orb IS the microphone. It already had a listening state and it already sits + // where the reference puts it, so a separate glyph beside it was two things saying + // one thing. Tapping starts dictation and the orb picks up; tapping again stops it. + // It fills the field rather than sending: dictation misreads identifiers, and a + // prompt you cannot correct before it runs is worse than typing it. + Button { + Task { await toggleDictation() } + } label: { + ThinkingOrb(state: dictation.listening ? .listening : .idle, size: 20) + .frame(width: 32, height: 32) + .opacity(dictation.available ? 1 : 0.3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!dictation.available) + Button(action: send) { + Image(systemName: "arrow.up") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(draft.isEmpty ? 0.25 : 0.9)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(draft.isEmpty || session.working) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var addressField: some View { + HStack(spacing: 8) { + Text("address") + .font(.custom(AppFont.asciiName, size: 10)) + .opacity(0.4) + TextField("model endpoint (host[:port])", text: $addressDraft) + .font(.custom(AppFont.asciiName, size: 12)) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .focused($addressFocused) + // Saved on return AND on tapping away: on a phone, leaving a field is how + // most people finish it, and a value that quietly evaporates on blur is a + // setting that never sticks. + .onSubmit { settings.setAddress(addressDraft, for: session.agent) } + .onChange(of: addressFocused) { was, is_ in + if was, !is_, !addressDraft.isEmpty { + settings.setAddress(addressDraft, for: session.agent) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(.bottom, 6) + .onAppear { addressDraft = settings.address(for: session.agent) } + .onChange(of: session.agent.id) { _, _ in + addressDraft = settings.address(for: session.agent) + } + } + + /// The one field an agent needs before it can answer, shown only while it is empty. Saved + /// on submit and not asked again — a key retyped every launch is a container nobody opens. + private func setup(_ setting: CodingAgent.Setting) -> some View { + HStack(spacing: 8) { + Text(setting.name) + .font(.custom(AppFont.asciiName, size: 10)) + .opacity(0.4) + Group { + if setting.secret { + SecureField(setting.placeholder, text: $setupDraft) + } else { + TextField(setting.placeholder, text: $setupDraft) + } + } + .font(.custom(AppFont.asciiName, size: 12)) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($setupFocused) + .onSubmit { commitSetup() } + // The same blur rule as the address: finished is finished, whether the finger + // found return or the next field. + .onChange(of: setupFocused) { was, is_ in + if was, !is_, !setupDraft.isEmpty { commitSetup() } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(.bottom, 8) + } + + private func commitSetup() { + // Commit the address too: someone who fills both fields and finishes once should not + // silently lose the one they did not submit. + if session.agent.embedded || session.agent.endpointVariable != nil, !addressDraft.isEmpty { + settings.setAddress(addressDraft, for: session.agent) + } + settings.set(setupDraft, for: session.agent) + setupDraft = "" + } + + // MARK: - Sign-in, the agent's own + + /// Starts the agent's documented sign-in program on the terminal screen, in here. + private var loginRow: some View { + Button { + Task { await session.login() } + } label: { + HStack(spacing: 8) { + Text("sign in") + .font(.custom(AppFont.asciiName, size: 12)) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.bottom, 8) + } + + /// The sign-in program's screen, where the exchange normally is. The input field below + /// keeps working — while a program owns the terminal, sending feeds it the line. + @ViewBuilder + private var loginScreen: some View { + if let terminal = session.terminal { + VStack(alignment: .leading, spacing: 8) { + GeometryReader { geo in + TerminalScreenGrid(terminal: terminal) + .onAppear { applyGrid(geo.size, terminal: terminal) } + .onChange(of: geo.size) { _, size in applyGrid(size, terminal: terminal) } + } + HStack(spacing: 8) { + if let url = signInURL(terminal) { + Link(destination: url) { + Text("open \(url.host() ?? "the sign-in page")") + .font(.custom(AppFont.asciiName, size: 11)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.white.opacity(0.1), + in: Capsule()) + } + } + Spacer(minLength: 0) + Button { + session.cancelLogin() + } label: { + Text("stop") + .font(.custom(AppFont.asciiName, size: 11)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.white.opacity(0.1), in: Capsule()) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + } + + private func applyGrid(_ size: CGSize, terminal: TerminalSession) { + terminal.setGridSize(rows: Int(size.height / TerminalCellMetrics.height), + columns: Int(size.width / TerminalCellMetrics.width)) + } + + /// The sign-in URL as the program printed it — reassembled across hard-wrapped rows (a + /// full-width row continues on the next) so the link carries the whole query string. + private func signInURL(_ terminal: TerminalSession) -> URL? { + _ = terminal.screenGeneration + let screen = terminal.screen + var joined = "" + for row in 0.. (String, String)? in + guard let role = m["role"] as? String, let content = m["content"] as? String else { return nil } + return (role, content) + } + do { + let reply = try await api.complete(asked.isEmpty ? [("user", prompt)] : asked) + recorded.append(reply) + } catch { + problem = "\(error)" + messages.append(Message(author: .agent, text: "\(error)")) + return + } + } + problem = "six steps without an answer" + } + + /// One step of Hermes's OWN loop — `AIAgent.chat` behind a replayed openai transport. + /// Recorded model replies replay in order; the first unrecorded call comes back as the + /// llm.complete tool, Mouse answers it on URLSession, and the step reruns. See the brief's + /// record-and-replay design; the driver is generated here so nothing ships half-configured. + private static let stepDriver = "# One step of Hermes's own loop, per invocation. Mouse wrote turn.json; this decides.\n#\n# Hermes's transport is the openai SDK, which cannot import here (pydantic-core is compiled)\n# and could not connect if it did (wasi has no sockets). So a stand-in `openai` goes into\n# sys.modules BEFORE hermes imports: recorded responses replay in order, and the first\n# unrecorded call raises Capture — written out as the llm.complete tool for Mouse's URLSession.\nimport json, sys, types, traceback\n\nBRIDGE = \"/.hermes-bridge\"\nturn = json.load(open(BRIDGE + \"/turn.json\"))\nprompt = turn[\"prompt\"]\nrecorded = turn.get(\"recorded\", [])\nmodel_name = turn.get(\"model\", \"hermes-agent\")\n\nclass Capture(BaseException):\n # BaseException on purpose: hermes wraps its model calls in retries that catch\n # Exception, and a captured request must walk straight through them to the driver.\n def __init__(self, request):\n self.request = request\n\n_replay = {\"next\": 0}\n_calls = []\n\nclass _Message:\n def __init__(self, content):\n self.role, self.content, self.tool_calls = \"assistant\", content, None\n def model_dump(self):\n return {\"role\": self.role, \"content\": self.content}\n\nclass _Choice:\n def __init__(self, content):\n self.message, self.finish_reason, self.index = _Message(content), \"stop\", 0\n\nclass _Response:\n def __init__(self, content):\n self.choices, self.usage, self.id, self.model = [_Choice(content)], None, \"replay\", model_name\n def model_dump(self):\n return {\"choices\": [{\"message\": self.choices[0].message.model_dump()}]}\n\ndef _create(**request):\n _calls.append({\"keys\": sorted(request.keys()), \"stream\": bool(request.get(\"stream\")),\n \"model\": request.get(\"model\")})\n i = _replay[\"next\"]\n if i < len(recorded):\n _replay[\"next\"] = i + 1\n return _Response(recorded[i])\n safe = {}\n for key in (\"messages\", \"model\", \"tools\", \"temperature\", \"max_tokens\", \"max_completion_tokens\"):\n if key in request:\n try:\n json.dumps(request[key])\n safe[key] = request[key]\n except (TypeError, ValueError):\n pass\n raise Capture(safe)\n\nclass _Delta:\n def __init__(self, content): self.content, self.role, self.tool_calls = content, \"assistant\", None\n\nclass _StreamChunk:\n def __init__(self, content, finish=None):\n choice = types.SimpleNamespace(delta=_Delta(content), finish_reason=finish, index=0)\n self.choices = [choice]\n self.id, self.model, self.usage = \"replay\", model_name, None\n\ndef _result(request):\n content_response = _create(**request) # replay or Capture\n if request.get(\"stream\"):\n return iter([_StreamChunk(content_response.choices[0].message.content),\n _StreamChunk(None, finish=\"stop\")])\n return content_response\n\nclass _Proxy:\n def __init__(self, path):\n self._path = path\n def __getattr__(self, name):\n if name.startswith(\"_\"):\n # Dunders, and private probes like `_client`: absent. hermes reads `_client`\n # to inspect the transport, and an ever-truthy proxy there reads as CLOSED.\n raise AttributeError(name)\n if name == \"is_closed\":\n # Asked both as a property and as a method; a plain False satisfies neither\n # branch wrongly — hermes calls it if callable, truth-tests it if not.\n return lambda: False\n if name in (\"close\", \"aclose\"):\n return lambda *a, **k: None\n return _Proxy(self._path + \".\" + name)\n def __call__(self, *args, **kwargs):\n _calls.append({\"path\": self._path, \"keys\": sorted(kwargs.keys()),\n \"stream\": bool(kwargs.get(\"stream\"))})\n if \"messages\" in kwargs:\n return _result(kwargs)\n # Construction and configuration chatter (with_options, headers, …): answer with\n # another proxy so the caller keeps walking to its real request.\n return _Proxy(self._path + \"()\")\n\ndef _build_fake_openai():\n fake = types.ModuleType(\"openai\")\n class OpenAI(_Proxy):\n def __init__(self, *a, **k):\n _Proxy.__init__(self, \"OpenAI\")\n class AsyncOpenAI(OpenAI): pass\n class APIError(Exception): pass\n class APIStatusError(APIError): pass\n class APIConnectionError(APIError): pass\n class APITimeoutError(APIConnectionError): pass\n class RateLimitError(APIStatusError): pass\n class AuthenticationError(APIStatusError): pass\n class BadRequestError(APIStatusError): pass\n class NotFoundError(APIStatusError): pass\n class InternalServerError(APIStatusError): pass\n for name, value in list(locals().items()):\n if not name.startswith(\"_\") and name != \"fake\":\n setattr(fake, name, value)\n fake.__version__ = \"0.0.0-mouse-replay\"\n return fake\n\nsys.modules[\"openai\"] = _build_fake_openai()\n\n# This build does not run `site`, so sitecustomize never loads — the runtime patches live\n# here, applied before hermes imports. wasi has no threads: a Timer never fires, a daemon\n# thread pretends to start (watchers, log listeners), a non-daemon thread runs INLINE.\n# This WASI has no clock sleep (poll_oneoff answers Not supported), and hermes's loop\n# sleeps 200ms between interrupt checks. There is nothing to yield to on one thread anyway.\nimport time as _time\n_time.sleep = lambda seconds=0: None\n\nimport threading as _threading\ndef _inline_start(self):\n self._started.set()\n if isinstance(self, _threading.Timer) or self.daemon:\n return\n self.run()\n_threading.Thread.start = _inline_start\n# join() on a pretend-started thread trips _wait_for_tstate_lock's assert; there is nothing\n# to wait for — inline threads already ran, daemons never will.\n_threading.Thread.join = lambda self, timeout=None: None\n_threading.Thread.is_alive = lambda self: False\n\nimport concurrent.futures as _cf\nclass _InlineExecutor(_cf.Executor):\n def __init__(self, *a, **k): pass\n def submit(self, fn, /, *args, **kwargs):\n future = _cf.Future()\n try:\n future.set_result(fn(*args, **kwargs))\n except BaseException as error:\n future.set_exception(error)\n return future\n def shutdown(self, wait=True, *, cancel_futures=False): pass\n_thread_mod = types.ModuleType(\"concurrent.futures.thread\")\n_thread_mod.ThreadPoolExecutor = _InlineExecutor\nsys.modules[\"concurrent.futures.thread\"] = _thread_mod\n_cf.ThreadPoolExecutor = _InlineExecutor\n\n# QueueListener.start is the one that actually fired: logging's queue machinery wants its\n# own thread. Listening inline means handling records as they are enqueued instead.\nimport logging.handlers as _lh\ndef _listener_start(self):\n class _Immediate:\n def __init__(self, listener): self._l = listener\n def put_nowait(self, record):\n if record is not None:\n self._l.handle(record)\n put = put_nowait\n def get(self, *a, **k): raise EOFError\n self.queue = _Immediate(self)\n_lh.QueueListener.start = _listener_start\n_lh.QueueListener.stop = lambda self: None\n\n# Hermes's own diagnostics, captured in-process: the QueueListener patch above orphans\n# its file logs, and the \"invalid response\" reason is logged, not raised.\nimport io, logging\n_logbuf = io.StringIO()\n_handler = logging.StreamHandler(_logbuf)\n_handler.setFormatter(logging.Formatter(\"%(name)s %(levelname)s %(message)s\"))\n_handler.setLevel(logging.DEBUG)\nlogging.getLogger(\"agent\").addHandler(_handler)\nlogging.getLogger(\"agent\").setLevel(logging.DEBUG)\nlogging.getLogger(\"run_agent\").addHandler(_handler)\nlogging.getLogger(\"run_agent\").setLevel(logging.DEBUG)\n\nout = {}\ntry:\n from run_agent import AIAgent\n # THE SEAM. The loop asks these two methods for a completed, OpenAI-shaped response;\n # everything below them is transport (worker threads, httpx streaming, retries) that\n # cannot exist on this device. Mouse IS the transport: recorded replies replay, the\n # first unrecorded call is captured for URLSession.\n def _mouse_transport(self, api_kwargs, **extra):\n _calls.append({\"transport\": sorted(api_kwargs.keys())})\n return _create(**api_kwargs)\n AIAgent._interruptible_streaming_api_call = _mouse_transport\n AIAgent._interruptible_api_call = _mouse_transport\n agent = AIAgent(base_url=\"http://mouse.bridge/v1\", api_key=\"mouse-bridge\", model=model_name)\n answer = agent.chat(prompt)\n out = {\"answer\": answer if isinstance(answer, str) else str(answer)}\nexcept Capture as capture:\n out = {\"tool\": \"llm.complete\", \"args\": capture.request}\nexcept BaseException as error:\n out = {\"error\": \"%s: %s\\n%s\" % (type(error).__name__, error, traceback.format_exc()[-1800:])}\n\nout[\"calls\"] = _calls\nout[\"log\"] = _logbuf.getvalue()[-2500:]\njson.dump(out, open(BRIDGE + \"/out.json\", \"w\"))\n" + + /// Run one command and wait for it to finish, answering with what it printed and whether it + /// FAILED. `TerminalSession.run` is fire-and-forget, so completion is observed rather than + /// awaited. + /// + /// Two things this got wrong, both of which turned a plain failure into `(no output)` on + /// screen. It waited only on `isRunning`, but a command that takes the terminal as a + /// full-screen `program` leaves that false — so the wait ended immediately and the next + /// command was refused, printing nothing at all. And it called any new line success, so + /// `msh: command not found: pip` counted as a successful install and the launch went ahead. + /// An error line is a failure, and its text is the most useful thing on the screen. + private func run(_ command: String, on terminal: TerminalSession) async -> (ok: Bool, text: String) { + let before = terminal.lines.count + // Screenless: this container has no terminal grid, and an agent handed one never + // returns. `claude -p` answers in three seconds down this path and hangs forever down + // the other. + guard terminal.run(command, screenless: true) else { return (false, "the terminal is busy") } + // BOUNDED. An installed bin that msh launches interactively becomes a full-screen + // program and owns the terminal until it decides to leave — `claude -p` does exactly + // that and was still holding it after ninety seconds with nothing printed. An unbounded + // wait here is a container that spins forever with no way to say why. + let deadline = Date().addingTimeInterval(Self.patience) + while terminal.isRunning || terminal.program != nil { + if Date() > deadline { + terminal.interrupt() + let printed = Array(terminal.lines[before...]).filter { $0.kind != .command } + .map(\.text).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return (false, printed.isEmpty + ? "\(command) is still running after \(Int(Self.patience))s and printed nothing" + : printed) + } + try? await Task.sleep(for: .milliseconds(120)) + } + let produced = Array(terminal.lines[before...]).filter { $0.kind != .command } + let failed = produced.contains { $0.kind == .error } + let text = produced.map(\.text).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return (!failed, text) + } + +} diff --git a/swift/Mouse/AgentSettings.swift b/swift/Mouse/AgentSettings.swift new file mode 100644 index 0000000..6c9c03d --- /dev/null +++ b/swift/Mouse/AgentSettings.swift @@ -0,0 +1,104 @@ +import Foundation +import Security + +/// The per-agent setup that has to survive a relaunch. +/// +/// Every agent here needs something before it can answer: Claude Code needs an API key, Hermes +/// needs the address of the machine running its gateway. Asking again on every launch would make +/// the container unusable, which is why the current Hermes grew savable profiles in the first +/// place — this is that idea, one profile per agent. +/// +/// A SECRET goes to the keychain, not to UserDefaults. An API key in a plist is readable by +/// anything that can read the container's files, including a backup of the phone. +@MainActor +@Observable +final class AgentSettings { + static let shared = AgentSettings() + + private init() {} + + /// The saved value for an agent's setting, or "" when nothing is stored. + func value(for agent: CodingAgent) -> String { + _ = version // the keychain and UserDefaults are invisible to @Observable; this read + // subscribes the calling view to the writes, which all bump `version` + guard let setting = agent.setting else { return "" } + return setting.secret + ? (Self.keychainRead(setting.name) ?? "") + : (UserDefaults.standard.string(forKey: Self.key(agent, setting)) ?? "") + } + + func set(_ value: String, for agent: CodingAgent) { + guard let setting = agent.setting else { return } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if setting.secret { + Self.keychainWrite(setting.name, trimmed) + } else { + UserDefaults.standard.set(trimmed, forKey: Self.key(agent, setting)) + } + version += 1 + } + + func isSet(for agent: CodingAgent) -> Bool { + agent.setting == nil || !value(for: agent).isEmpty + } + + /// Bumped on every write so views observing this object redraw — the values themselves live + /// in the keychain and UserDefaults, which `@Observable` cannot see into. + private(set) var version = 0 + + /// Where the agent's API server is, or "" for the documented default. Not a secret and not + /// yet asked for in the UI: `hermes gateway` binds 127.0.0.1:8642 and the simulator can + /// reach that, so the default is right until someone runs it elsewhere. + func address(for agent: CodingAgent) -> String { + _ = version // same subscription as `value(for:)` + return UserDefaults.standard.string(forKey: "agent.\(agent.id).address") ?? "" + } + + func setAddress(_ value: String, for agent: CodingAgent) { + UserDefaults.standard.set(value.trimmingCharacters(in: .whitespaces), + forKey: "agent.\(agent.id).address") + version += 1 + } + + /// The shell line that puts the setting where the agent's own CLI looks for it. `export` is + /// how a person would do it, and the agent is being driven the way a person would. + func exportLine(for agent: CodingAgent) -> String? { + guard let setting = agent.setting, setting.exported else { return nil } + let value = self.value(for: agent) + guard !value.isEmpty else { return nil } + return "export \(setting.name)=\(value)" + } + + private static func key(_ agent: CodingAgent, _ setting: CodingAgent.Setting) -> String { + "agent.\(agent.id).\(setting.name)" + } + + // MARK: - Keychain + + private static func query(_ name: String) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.reagentsystems.mouse.agent", + kSecAttrAccount as String: name] + } + + private static func keychainRead(_ name: String) -> String? { + var request = query(name) + request[kSecReturnData as String] = true + request[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(request as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func keychainWrite(_ name: String, _ value: String) { + SecItemDelete(query(name) as CFDictionary) + guard !value.isEmpty else { return } + var request = query(name) + request[kSecValueData as String] = Data(value.utf8) + // The phone is unlocked whenever the container is on screen, and this must not sync to + // another device the user did not set up. + request[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + SecItemAdd(request as CFDictionary, nil) + } +} diff --git a/swift/Mouse/CarouselDeck.swift b/swift/Mouse/CarouselDeck.swift index 3668df4..6dca7f6 100644 --- a/swift/Mouse/CarouselDeck.swift +++ b/swift/Mouse/CarouselDeck.swift @@ -372,7 +372,7 @@ extension ContainerType { /// are retired — Android's deck already ships only these five, and a snapshot that still /// carries a placeholder drops it at restore. static func catalog() -> [ContainerType] { - [gitHubKind, filesKind, viewerKind, graphKind, terminalKind].map { entry(kind: $0) } + [gitHubKind, filesKind, viewerKind, graphKind, terminalKind, agentKind].map { entry(kind: $0) } } /// Build an instance of a given catalog type — fresh by default, or with a persisted identity. @@ -396,12 +396,18 @@ extension ContainerType { static let graphKind = 4 /// Catalog kind 5 is the Terminal container: a native command dispatcher on the workspace. static let terminalKind = 5 + /// Catalog kind 16 is the Agent container: a coding agent, voice or typed, on the workspace. + /// SIXTEEN, not six. Kinds 6–15 were the numbered placeholders this ring used to carry, and + /// a snapshot written before they retired can still hold one — reusing 6 would restore that + /// dead placeholder wearing the Agent's name, with its old title and colour. A kind number + /// is an identity in persisted data, so a retired one stays retired. + static let agentKind = 16 /// Containers with real surfaces (they render their own content, terminal-styled black). - static let realKinds: Set = [gitHubKind, filesKind, viewerKind, graphKind, terminalKind] + static let realKinds: Set = [gitHubKind, filesKind, viewerKind, graphKind, terminalKind, agentKind] static let realTitles: [Int: String] = [ gitHubKind: "GitHub", filesKind: "Files", viewerKind: "Viewer", graphKind: "Graph", - terminalKind: "Terminal", + terminalKind: "Terminal", agentKind: "Agent", ] static let swipePresetKind = 0 diff --git a/swift/Mouse/Dictation.swift b/swift/Mouse/Dictation.swift new file mode 100644 index 0000000..05d83d3 --- /dev/null +++ b/swift/Mouse/Dictation.swift @@ -0,0 +1,112 @@ +import AVFoundation +import Foundation +import Speech + +/// Speech to text for the Agent container's input, ON DEVICE. +/// +/// `requiresOnDeviceRecognition` is set, not merely preferred: the alternative sends recorded +/// audio of whatever is said near the phone to Apple's servers, and a coding agent's input is +/// the user's own source. On-device recognition is less accurate on identifiers and symbols, and +/// that is the trade this app takes. A locale with no on-device model simply reports unavailable. +@MainActor +@Observable +final class Dictation { + /// Live text while speaking, replaced on every partial result. + private(set) var transcript = "" + private(set) var listening = false + /// Set when a request cannot proceed — permission refused, no model, no recognizer. + private(set) var problem: String? + + private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")) + private var request: SFSpeechAudioBufferRecognitionRequest? + private var task: SFSpeechRecognitionTask? + private let engine = AVAudioEngine() + + var available: Bool { recognizer?.isAvailable == true } + + /// Ask for both permissions, then start. Two prompts on the first run — the microphone is a + /// separate grant from recognition, and iOS shows them one at a time. + func start() async { + guard !listening else { return } + problem = nil + guard let recognizer, recognizer.isAvailable else { + problem = "speech recognition unavailable" + return + } + guard await Self.authorizeSpeech(), await Self.authorizeMicrophone() else { + problem = "microphone or speech access refused" + return + } + do { + try beginCapture(with: recognizer) + listening = true + } catch { + problem = "\(error.localizedDescription)" + stop() + } + } + + func stop() { + guard listening || engine.isRunning else { return } + engine.stop() + engine.inputNode.removeTap(onBus: 0) + request?.endAudio() + task?.cancel() + request = nil + task = nil + listening = false + // The session is handed back so a program's own audio, and the ordinary ring silence, + // are not left behind a recording category. + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + /// Take what was said and clear it, so the next dictation starts empty. + func take() -> String { + let text = transcript + transcript = "" + return text + } + + private func beginCapture(with recognizer: SFSpeechRecognizer) throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.record, mode: .measurement, options: .duckOthers) + try session.setActive(true, options: .notifyOthersOnDeactivation) + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + request.requiresOnDeviceRecognition = true + self.request = request + + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in + request.append(buffer) + } + engine.prepare() + try engine.start() + + task = recognizer.recognitionTask(with: request) { [weak self] result, error in + Task { @MainActor in + guard let self else { return } + if let result { + self.transcript = result.bestTranscription.formattedString + if result.isFinal { self.stop() } + } + if error != nil { self.stop() } + } + } + } + + private static func authorizeSpeech() async -> Bool { + if SFSpeechRecognizer.authorizationStatus() == .authorized { return true } + return await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0 == .authorized) } + } + } + + private static func authorizeMicrophone() async -> Bool { + await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { continuation.resume(returning: $0) } + } + } +} diff --git a/swift/Mouse/ForegroundView.swift b/swift/Mouse/ForegroundView.swift index 9985101..e6caad2 100644 --- a/swift/Mouse/ForegroundView.swift +++ b/swift/Mouse/ForegroundView.swift @@ -840,6 +840,9 @@ struct Panel: View { } else if type.kind == ContainerType.terminalKind { TerminalContainerView(deck: deck) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } else if type.kind == ContainerType.agentKind { + AgentContainerView(deck: deck) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) } else if !type.usesGapLabel { Text(type.displayTitle) .font(type.isOnboardingPreset diff --git a/swift/Mouse/Info.plist b/swift/Mouse/Info.plist index 1f85064..d716671 100644 --- a/swift/Mouse/Info.plist +++ b/swift/Mouse/Info.plist @@ -18,6 +18,10 @@ 1.0 CFBundleVersion 1 + NSMicrophoneUsageDescription + Dictating prompts to the coding agent. + NSSpeechRecognitionUsageDescription + Turning dictated prompts into text, on this device. UIAppFonts IBMPlexMono-Bold.ttf diff --git a/swift/Mouse/NodeEngine.swift b/swift/Mouse/NodeEngine.swift index 098a701..a72c86b 100644 --- a/swift/Mouse/NodeEngine.swift +++ b/swift/Mouse/NodeEngine.swift @@ -57,6 +57,14 @@ final class NodeEngine: @unchecked Sendable { private var tty: TTY? private let queue = DispatchQueue(label: "mouse.node", qos: .userInitiated) private var context: JSContext! + /// A hook run once, right after the JSContext exists — the extension point diagnostics + /// hang off. The Mac-side probes use it to arm a JSC job watchdog through dlsym'd private + /// API, which must never ship in the app; the app leaves both of these nil. The static + /// form exists because probes drive engines the SHELL creates, out of their reach. + var contextConfigurator: ((JSContext) -> Void)? + nonisolated(unsafe) static var globalContextConfigurator: ((NodeEngine, JSContext) -> Void)? + /// A live backtrace captured by such a diagnostic, surfaced into `err` when the run ends. + var watchdogBacktrace: String? private var virtualMachine: JSVirtualMachine! /// `vm` contexts, each a separate global in the same virtual machine. private var vmContexts: [Int: JSContext] = [:] @@ -99,6 +107,18 @@ final class NodeEngine: @unchecked Sendable { private let jobsLock = NSLock() private var jobs: [() -> Void] = [] private var outstanding = 0 + /// Why `outstanding` is what it is, by label — the interrupt report reads this, because + /// "1 host call in flight" cost a day and "1 http request in flight" costs a minute. + private var outstandingWhy: [String: Int] = [:] + private func hold(_ why: String) { + outstanding += 1 + outstandingWhy[why, default: 0] += 1 + } + private func release(_ why: String) { + outstanding -= 1 + outstandingWhy[why, default: 0] -= 1 + if outstandingWhy[why] == 0 { outstandingWhy.removeValue(forKey: why) } + } /// MessagePort deliveries. Node runs these in their OWN loop phase: after nextTick and the /// microtask queue, before immediates — verified against real node, including the case where /// the nextTick is queued AFTER the postMessage and still runs first. A microtask-based drain @@ -143,7 +163,7 @@ final class NodeEngine: @unchecked Sendable { enqueueJob { [weak self] in guard let self, webSocketTasks[id] != nil else { return } webSocketTasks[id] = nil - outstanding -= 1 + release("engine job") } } /// A forked child's message channel back to its parent. nil when there is none, which is @@ -335,6 +355,8 @@ final class NodeEngine: @unchecked Sendable { // sandbox possible at all. virtualMachine = JSVirtualMachine()! context = JSContext(virtualMachine: virtualMachine)! + contextConfigurator?(context) + Self.globalContextConfigurator?(self, context) context.name = "mouse-node" var fatal: String? = nil var fatalValue: JSValue? = nil @@ -480,6 +502,10 @@ final class NodeEngine: @unchecked Sendable { // process.exit(), or a fatal error. Anything it writes still belongs in the output. context.objectForKeyedSubscript("__mouseEmitExit")?.call(withArguments: [exitCode ?? 0]) + if let trace = watchdogBacktrace { + err += "job watchdog: a single job overran and was terminated. Its live stack:\n" + + remapStack(trace) + "\n" + } return Result(out: out, err: err, status: exitCode ?? 0) } @@ -667,7 +693,37 @@ final class NodeEngine: @unchecked Sendable { private func runEventLoop() { while exitCode == nil { - if cancelled { exitCode = 130; break } + if cancelled { + // An interrupt is the one moment the question "what was this waiting ON" has + // an answerable, useful answer — a run that had to be killed was waiting on + // SOMETHING, and the loop's own liveness accounting knows what. claude-code's + // silent hang burned days for want of this line. + let refed = timers.filter(\.refed) + var reasons: [String] = [] + if !refed.isEmpty { + let soonest = refed.map { $0.due.timeIntervalSinceNow }.min() ?? 0 + reasons.append("\(refed.count) timer\(refed.count == 1 ? "" : "s") (next in \(String(format: "%.1f", max(0, soonest)))s)") + } + if outstanding > 0 { + let named = outstandingWhy.map { "\($0.value)× \($0.key)" }.sorted().joined(separator: ", ") + reasons.append(named.isEmpty ? "\(outstanding) host calls in flight" : named) + } + if hasOpenHandles { reasons.append("open sockets/servers") } + if stdinActive { reasons.append("stdin listeners") } + if pendingLookups > 0 { reasons.append("\(pendingLookups) dns lookup\(pendingLookups == 1 ? "" : "s")") } + if !reasons.isEmpty { + err += "interrupted while waiting on: " + reasons.joined(separator: ", ") + "\n" + } else { + // Interrupted with NOTHING pending and the loop still alive: the program was + // not waiting, it was RUNNING — a synchronous spin the quiescence checks + // never got a chance to see. Saying so separates "stuck on IO" from "stuck + // in a loop", which are different bugs in different places. + err += "interrupted while busy: nothing pending — the program was computing " + + "or spinning, not waiting\n" + } + exitCode = 130 + break + } drainTicks() jobsLock.lock() @@ -1034,7 +1090,14 @@ final class NodeEngine: @unchecked Sendable { } let renamePath: @convention(block) (String, String) -> Bool = { [weak self] from, to in guard let self else { return false } - return (try? FileManager.default.moveItem(at: self.realURL(from), to: self.realURL(to))) != nil + // POSIX rename, not FileManager.moveItem: rename(2) REPLACES an existing + // destination atomically, which is the entire point of the tmp-then-rename pattern + // every tool's atomic config write uses. moveItem refuses when the destination + // exists, and swallowing that refusal left claude-code's .claude.json.tmp.* files + // stranded beside a config that never updated — with rc=0 and no output, because + // the CLI treats its own config write as best-effort. + let source = self.realURL(from), destination = self.realURL(to) + return rename(source.path, destination.path) == 0 } // statfs(2) — free space and block counts. Build tools check available space before // writing large artifacts, and node exports it. @@ -1153,17 +1216,17 @@ final class NodeEngine: @unchecked Sendable { guard let self else { return } if hold, !channelHoldsLoop { channelHoldsLoop = true - outstanding += 1 + self.hold("child ipc") } else if !hold, channelHoldsLoop { channelHoldsLoop = false - outstanding -= 1 + release("child ipc") wakeup.signal() } } let ipcDisconnect: @convention(block) () -> Void = { [weak self] in guard let self, channelHoldsLoop else { return } channelHoldsLoop = false - outstanding -= 1 + release("child ipc") wakeup.signal() } expose("ipcHold", ipcHold) @@ -1191,7 +1254,7 @@ final class NodeEngine: @unchecked Sendable { let isEval = mode.hasPrefix("eval") let carried = Carried(trampolined(callback)) let id = sockets.claimExternalID() - outstanding += 1 + hold("spawned child") // `options.env` finally reaches the child. A caller that passes env expects exactly // it (node REPLACES the environment rather than merging), and the JS side is what // decides whether to inherit — same as node, where `{...process.env}` is the caller's @@ -1254,7 +1317,7 @@ final class NodeEngine: @unchecked Sendable { carried.value.call(withArguments: ["stderr", message]) carried.value.call(withArguments: ["exit", 1]) self.children[id] = nil - if self.refedChildren.remove(id) != nil { self.outstanding -= 1 } + if self.refedChildren.remove(id) != nil { self.release("child ref") } } return Int32(id) } @@ -1273,7 +1336,7 @@ final class NodeEngine: @unchecked Sendable { self.children[id] = nil // Only give back the handle if it is still held; an unref'd child already // returned it. - if self.refedChildren.remove(id) != nil { self.outstanding -= 1 } + if self.refedChildren.remove(id) != nil { self.release("child ref") } } } return Int32(id) @@ -1304,17 +1367,17 @@ final class NodeEngine: @unchecked Sendable { // other handle here is owned by the host side (a socket, a child, a timer). let loopHold: @convention(block) (Bool) -> Void = { [weak self] hold in guard let self else { return } - outstanding += hold ? 1 : -1 + if hold { self.hold("loop hold") } else { self.release("loop hold") } } expose("loopHold", loopHold) let spawnRef: @convention(block) (Int32, Bool) -> Void = { [weak self] id, refed in guard let self, children[Int(id)] != nil else { return } if refed, !refedChildren.contains(Int(id)) { refedChildren.insert(Int(id)) - outstanding += 1 + hold("child ref") } else if !refed, refedChildren.contains(Int(id)) { refedChildren.remove(Int(id)) - outstanding -= 1 + release("child ref") } } expose("spawnRef", spawnRef) @@ -1335,11 +1398,11 @@ final class NodeEngine: @unchecked Sendable { request.httpMethod = method for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) } if !bodyBase64.isEmpty { request.httpBody = Data(base64Encoded: bodyBase64) } - self.outstanding += 1 + self.hold("http request") let carried = Carried(trampolined(callback)) URLSession.shared.dataTask(with: request) { data, response, error in self.enqueueJob { - self.outstanding -= 1 + self.release("http request") if let error { carried.value.call(withArguments: [["error": error.localizedDescription]]) return @@ -1382,13 +1445,16 @@ final class NodeEngine: @unchecked Sendable { request.httpMethod = method for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) } if !bodyBase64.isEmpty { request.httpBody = Data(base64Encoded: bodyBase64) } - outstanding += 1 + // The label carries the DESTINATION: "1× http stream" says a request is stuck, + // "http stream to api.anthropic.com" says which one. + let label = "http stream to \(url.host ?? urlText)" + hold(label) let collector = StreamCollector( deliver: { [weak self] event, payload in self?.enqueueJob { carried.value.call(withArguments: [event, payload]) } }, finished: { [weak self] in - self?.enqueueJob { self?.outstanding -= 1 } + self?.enqueueJob { self?.release(label) } }) // A delegate session must be invalidated or it retains its delegate forever; // StreamCollector does that when the task completes. @@ -1410,7 +1476,7 @@ final class NodeEngine: @unchecked Sendable { self?.enqueueJob { carried.value.call(withArguments: [event, payload]) } } let id = sockets.claimExternalID() - outstanding += 1 + hold("websocket") // `open` comes from the DELEGATE's handshake callback, not from a ping round-trip: // a ping races the first inbound frame, so the server's greeting could arrive // before the open event — node fires open first, always. The gate below also holds @@ -1597,7 +1663,7 @@ final class NodeEngine: @unchecked Sendable { carried.value.call(withArguments: [records ?? [], code ?? ""]) } } - outstanding += 1 + hold("dns") pendingLookups += 1 } let dnsReverse: @convention(block) (String, JSValue) -> Void = { [weak self] address, callback in @@ -1608,7 +1674,7 @@ final class NodeEngine: @unchecked Sendable { carried.value.call(withArguments: [names ?? [], code ?? ""]) } } - outstanding += 1 + hold("dns") pendingLookups += 1 } let dnsService: @convention(block) (String, Int32, JSValue) -> Void = { [weak self] address, port, callback in @@ -1619,7 +1685,7 @@ final class NodeEngine: @unchecked Sendable { carried.value.call(withArguments: [host ?? "", service ?? "", code ?? ""]) } } - outstanding += 1 + hold("dns") pendingLookups += 1 } // Every completion above releases the handle it took, so a program waiting only on a @@ -1627,7 +1693,7 @@ final class NodeEngine: @unchecked Sendable { let dnsDone: @convention(block) () -> Void = { [weak self] in guard let self, pendingLookups > 0 else { return } pendingLookups -= 1 - outstanding -= 1 + release("dns") } expose("dnsResolve", dnsResolve) expose("dnsReverse", dnsReverse) @@ -2576,7 +2642,7 @@ final class NodeEngine: @unchecked Sendable { "fs", "path", "os", "util", "events", "buffer", "tty", "assert", "url", "child_process", "http", "https", "net", "crypto", "stream", "zlib", "readline", "readline/promises", "string_decoder", "constants", "querystring", - "fs/promises", "stream/promises", "process", "module", "timers", "timers/promises", + "fs/promises", "stream/promises", "stream/consumers", "stream/web", "process", "module", "timers", "timers/promises", "path/posix", "path/win32", "http2", "tls", "dns", "worker_threads", "async_hooks", "v8", "vm", "perf_hooks", "inspector", "dgram", "cluster", "diagnostics_channel", "console", "util/types", "domain", "wasi", @@ -3380,6 +3446,15 @@ final class NodeEngine: @unchecked Sendable { } static func transpileESM(_ source: String, liveBindings: Bool = true) -> String { + // Live-binding promotion runs regex shadow scans over the WHOLE source per imported + // name, and ICU's matcher is superlinear on patterns like `\([^()]*name[^()]*\)` + // against megabyte-long minified lines — claude-code 2.1.98 loads such a chunk on its + // authenticated path, and a `sample` mid-hang put 2331 of 2334 ticks inside + // RegexMatcher::find under transpileESM. A bundle that size is a build artifact, not a + // hand-written module whose `export let` needs live reads; the snapshot path is the + // one this engine used for its whole life before live bindings, and it is correct for + // everything a bundler emits. Vite's biggest real chunk (2.1 MB) stays promoted. + let liveBindings = liveBindings && source.utf16.count < 4_000_000 var text = source // Emitted BEFORE the body: function declarations are hoisted, so a cycle reaching back // into this module finds them, and every export reads through a getter rather than @@ -3890,6 +3965,17 @@ final class NodeEngine: @unchecked Sendable { const bridge = __mouse; globalThis.global = globalThis; + // Explicit resource management (ES2026 `using`): this JSC does not define the well-known + // symbols yet, and bundles compiled against them (claude-code 2.x) throw "Object not + // disposable" from their own helpers when the symbol lookup comes back undefined. The + // engine already attaches [Symbol.dispose] to timers, so the polyfill must come FIRST. + if (!Symbol.dispose) { + Object.defineProperty(Symbol, 'dispose', { value: Symbol.for('nodejs.dispose') }); + } + if (!Symbol.asyncDispose) { + Object.defineProperty(Symbol, 'asyncDispose', { value: Symbol.for('nodejs.asyncDispose') }); + } + // ---- Buffer (Uint8Array + encodings) ---- function utf8Encode(str) { const bytes = []; @@ -8809,14 +8895,17 @@ final class NodeEngine: @unchecked Sendable { type: function(){ return 'Darwin'; }, arch: function(){ return 'arm64'; }, release: function(){ return '23.0.0'; }, - homedir: function(){ return '/'; }, + // Real node's rule: $HOME wins, and only then the platform account. The Agent + // container leans on this — it exports HOME=/home so every agent shares one home + // (credentials, config) while cwd stays the project. + homedir: function(){ return process.env.HOME || '/'; }, tmpdir: function(){ return '/tmp'; }, hostname: function(){ return 'mouse'; }, cpus: function(){ return [{ model: 'Apple', speed: 0, times: {} }]; }, totalmem: function(){ return 4 * 1024 * 1024 * 1024; }, freemem: function(){ return 1024 * 1024 * 1024; }, EOL: '\n', - userInfo: function(){ return { username: 'mouse', homedir: '/', shell: '/bin/msh' }; }, + userInfo: function(){ return { username: 'mouse', homedir: process.env.HOME || '/', shell: '/bin/msh' }; }, endianness: function(){ return 'LE'; }, uptime: function(){ return Math.floor(Date.now() / 1000) % 86400; }, loadavg: function(){ return [0, 0, 0]; }, @@ -11585,6 +11674,37 @@ final class NodeEngine: @unchecked Sendable { return Stream; }; coreFactories['stream/promises'] = function() { return coreRequire('stream').promises; }; + // node:stream/consumers — the five drain-it-all helpers. Accepts node streams and web + // ReadableStreams alike, because callers hand it whichever they have (claude-code does). + coreFactories['stream/consumers'] = function() { + async function collect(stream) { + const chunks = []; + if (stream && typeof stream.getReader === 'function') { + const reader = stream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(Buffer.from(value)); + } + } else { + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); + } + return { + buffer: collect, + text: async function(stream) { return (await collect(stream)).toString('utf8'); }, + json: async function(stream) { return JSON.parse((await collect(stream)).toString('utf8')); }, + arrayBuffer: async function(stream) { + const buf = await collect(stream); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + }, + blob: async function(stream) { + const buf = await collect(stream); + return new Blob([buf]); + }, + }; + }; coreFactories.constants = function() { return {}; }; coreFactories.querystring = function() { @@ -12029,11 +12149,16 @@ final class NodeEngine: @unchecked Sendable { }, spawnSync: function(command, argv, options) { options = options || {}; - // These cannot be honoured on the msh path — there is no live child to feed, kill or - // measure, because a synchronous run reports what the command PRODUCED. Saying so - // beats accepting them: an ignored `input` leaves a program waiting for output that - // depends on stdin it thinks it sent, and an ignored `timeout` never fires. - for (const unsupported of ['input', 'timeout', 'maxBuffer', 'killSignal']) { + // `input` cannot be honoured on the msh path — there is no live child to feed, and + // an ignored `input` leaves a program parsing output that depends on stdin it + // thinks it sent. The OTHER guard rails (`timeout`, `maxBuffer`, `killSignal`) are + // accepted and ignored now: a synchronous msh run has already COMPLETED by the + // time they could matter, so a timeout that never fires is the truth, not a lie — + // and refusing them killed real programs. claude-code 2.x probes ripgrep with + // spawnSync{timeout}, the refusal became an unhandled rejection, and the CLI died + // silently at startup. Guarding against a hang that cannot happen cost the whole + // program. + for (const unsupported of ['input']) { if (options[unsupported] !== undefined) { throw Object.assign( new Error("child_process.spawnSync's `" + unsupported + "` option is not " + diff --git a/swift/Mouse/PackageManager.swift b/swift/Mouse/PackageManager.swift index 4cb43ff..963f2e4 100644 --- a/swift/Mouse/PackageManager.swift +++ b/swift/Mouse/PackageManager.swift @@ -396,7 +396,12 @@ enum PackageManager { /// `@rollup/wasm-node`. nil when the package is itself. var installedAs: String? = nil /// Whether this placement sits at the root of node_modules (its bins join .bin). - var atRoot: Bool { !path.dropFirst("node_modules/".count).contains("/") } + /// + /// The test is "no FURTHER node_modules", not "no slash". A scoped package lives at + /// `node_modules/@scope/name`, so the slash test called every one of them nested and + /// dropped its bins — `npm i -g @anthropic-ai/claude-code` reported "added 1 packages" + /// and left no `claude` command behind, and the same was true of every scoped CLI. + var atRoot: Bool { !path.dropFirst("node_modules/".count).contains("node_modules/") } } /// Packages whose npm release is a per-platform NATIVE binary, mapped to the WebAssembly diff --git a/swift/Mouse/PipInstaller.swift b/swift/Mouse/PipInstaller.swift new file mode 100644 index 0000000..095ee03 --- /dev/null +++ b/swift/Mouse/PipInstaller.swift @@ -0,0 +1,227 @@ +import Foundation + +/// `pip install`, the subset a wasi CPython can honour: pure-Python wheels. +/// +/// The on-device Python has no pip and no ensurepip, and its wasi build cannot load a compiled +/// extension at all — so a real pip would mostly be a machine for producing confusing failures. +/// What CAN work is exactly this: a wheel is a zip (`ZipArchive` already reads those), PyPI's +/// JSON API is the registry, and a `py3-none-any` wheel unpacked into a site-packages directory +/// on `PYTHONPATH` is a working install. A package whose only wheels are compiled says so in one +/// line instead of failing at import time. +/// +/// This is the first stage of embedding Hermes: the agent loop is pure Python, and the pieces +/// that are not get delegated to Mouse itself. +enum Pip { + + struct PipError: Error, CustomStringConvertible { + let message: String + init(_ message: String) { self.message = message } + var description: String { message } + } + + /// Where installed wheels land: inside the python runtime's directory, which the shell + /// mounts at `/usr/lib/python` — Runtimes.json puts `{root}/site-packages` on PYTHONPATH. + static var sitePackages: URL { + RuntimeStore.root.appendingPathComponent("python/site-packages", isDirectory: true) + } + + /// Standard-library holes this wasi build has that PURE code can paper over at import + /// time. Laid down whenever pip touches the site-packages dir and refreshed every time: + /// `import ssl` is something half the ecosystem does defensively — asyncio itself pulls it + /// in — and an import that explodes on arrival hides code that would run fine delegating + /// its network to Mouse. USE of the shim refuses in words. + private static let stdlibShims: [(file: String, source: String)] = [ + ("ssl.py", "# This CPython wasi build has no _ssl and can never load one. Python-side TLS does not\n# exist here BY DESIGN: network with TLS is Mouse's, reached through the agent's tools.\n# This shim exists so `import ssl` — which half the ecosystem does defensively — succeeds,\n# and any actual USE says what is going on instead of pretending.\nclass SSLError(OSError): pass\nclass SSLCertVerificationError(SSLError): pass\nclass SSLZeroReturnError(SSLError): pass\nclass SSLWantReadError(SSLError): pass\nclass SSLWantWriteError(SSLError): pass\nclass SSLSyscallError(SSLError): pass\nclass SSLEOFError(SSLError): pass\nCertificateError = SSLCertVerificationError\n\nCERT_NONE, CERT_OPTIONAL, CERT_REQUIRED = 0, 1, 2\nPROTOCOL_TLS, PROTOCOL_TLS_CLIENT, PROTOCOL_TLS_SERVER = 2, 16, 17\nHAS_SNI = False\nHAS_ALPN = False\nOP_NO_COMPRESSION = 0x20000\nOP_NO_TICKET = 0x4000\n\nclass TLSVersion:\n MINIMUM_SUPPORTED = -2\n TLSv1_2 = 771\n TLSv1_3 = 772\n MAXIMUM_SUPPORTED = -1\n\ndef _refuse(*_a, **_k):\n raise SSLError(\"no TLS in this Python — network runs through Mouse's tools\")\n\nclass SSLContext:\n def __init__(self, protocol=PROTOCOL_TLS_CLIENT, *a, **k):\n self.protocol = protocol\n self.check_hostname = True\n self.verify_mode = CERT_REQUIRED\n self.minimum_version = TLSVersion.TLSv1_2\n self.maximum_version = TLSVersion.MAXIMUM_SUPPORTED\n self.options = 0\n def load_default_certs(self, *a, **k): pass\n def load_verify_locations(self, *a, **k): pass\n def load_cert_chain(self, *a, **k): pass\n def set_ciphers(self, *a, **k): pass\n def set_alpn_protocols(self, *a, **k): pass\n def get_ca_certs(self, binary_form=False):\n # Non-empty on purpose: hermes's ssl_guard treats an empty store as a broken\n # install. The store is Mouse's URLSession trust, not this context's.\n return [{\"subject\": (((\"commonName\", \"trust lives in Mouse\"),),)}]\n def cert_store_stats(self):\n return {\"x509\": 1, \"crl\": 0, \"x509_ca\": 1}\n wrap_socket = _refuse\n wrap_bio = _refuse\n\ndef create_default_context(*a, **k):\n return SSLContext()\n\ndef _create_unverified_context(*a, **k):\n return SSLContext()\n\nclass SSLObject: pass\nclass MemoryBIO:\n def __init__(self): self._eof = False\n @property\n def pending(self): return 0\n @property\n def eof(self): return self._eof\n def read(self, *a): return b\"\"\n def write(self, *a): _refuse()\n def write_eof(self): self._eof = True\n\nclass Purpose:\n SERVER_AUTH = \"1.3.6.1.5.5.7.3.1\"\n CLIENT_AUTH = \"1.3.6.1.5.5.7.3.2\"\n\nOPENSSL_VERSION = \"mouse-ssl-shim (no TLS; network is Mouse's)\"\nOPENSSL_VERSION_INFO = (0, 0, 0, 0, 0)\nOPENSSL_VERSION_NUMBER = 0\nCHANNEL_BINDING_TYPES = []\nVERIFY_DEFAULT = 0\nVERIFY_X509_STRICT = 0x20\nVERIFY_X509_TRUSTED_FIRST = 0x8000\ndef match_hostname(cert, hostname): _refuse()\ndef DER_cert_to_PEM_cert(der): _refuse()\ndef PEM_cert_to_DER_cert(pem): _refuse()\nclass SSLSocket:\n def __getattr__(self, name): _refuse()\n\nwrap_socket = _refuse\nget_default_verify_paths = lambda: None\n"), + ("webbrowser.py", "# Not in this wasi build's stdlib zip. There is no browser to open on this side anyway —\n# the container shows URLs to the user; opening one is a Mouse affordance, not Python's.\nclass Error(Exception): pass\n\ndef open(url, new=0, autoraise=True):\n return False\ndef open_new(url): return open(url, 1)\ndef open_new_tab(url): return open(url, 2)\ndef get(using=None): raise Error(\"no browser inside the agent runtime\")\ndef register(*a, **k): pass\n"), + ("sitecustomize.py", "# Startup patches for holes in this wasi build, imported by `site` on every run.\n# wasi has no threads, and the build omits concurrent.futures.thread entirely. An executor\n# that runs the callable INLINE at submit() is the truthful single-threaded degradation:\n# same Future surface, work done on the only thread there is.\nimport sys, types\nimport concurrent.futures as _cf\n\n_thread_mod = types.ModuleType('concurrent.futures.thread')\n\nclass ThreadPoolExecutor(_cf.Executor):\n def __init__(self, max_workers=None, thread_name_prefix=\"\", *a, **k):\n self._shutdown = False\n def submit(self, fn, /, *args, **kwargs):\n future = _cf.Future()\n try:\n future.set_result(fn(*args, **kwargs))\n except BaseException as error:\n future.set_exception(error)\n return future\n def map(self, fn, *iterables, timeout=None, chunksize=1):\n return map(fn, *iterables)\n def shutdown(self, wait=True, *, cancel_futures=False):\n self._shutdown = True\n\n_thread_mod.ThreadPoolExecutor = ThreadPoolExecutor\nsys.modules['concurrent.futures.thread'] = _thread_mod\n_cf.ThreadPoolExecutor = ThreadPoolExecutor\n\n# wasi has no threads at all — thread_create simply does not exist. Three truthful\n# degradations, by what the thread is FOR:\n# a Timer never fires (it would otherwise block the only thread for its whole interval),\n# a daemon thread pretends to start (they are watchers and keepalives),\n# a non-daemon thread runs INLINE at start(), which is what one thread of execution means.\nimport threading as _threading\n\ndef _inline_start(self):\n self._started.set()\n if isinstance(self, _threading.Timer):\n return\n if self.daemon:\n return\n try:\n self.run()\n finally:\n pass\n\n_threading.Thread.start = _inline_start\n"), + ] + + static func layShims(in target: URL) { + for shim in stdlibShims { + try? shim.source.write(to: target.appendingPathComponent(shim.file), + atomically: true, encoding: .utf8) + } + } + + /// Install packages and their dependency closure. `names` accepts `name` or `name==1.2.3`. + /// Every landed wheel is reported through `note`; already-present packages are skipped. + /// + /// A package that CANNOT land (no pure wheel) is fatal only when it was asked for by name. + /// A transitive one is skipped and reported instead — one compiled dep deep in a closure + /// used to abandon everything still queued behind it, so `openai` lost its own dependencies + /// to hermes's pyyaml. Whether a skipped dep actually matters is measured at import time, + /// which is a real answer; refusing the whole closure was a guess. + static func install(_ names: [String], into destination: URL? = nil, + note: @escaping @Sendable (String) -> Void) async throws { + let target = destination ?? sitePackages + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + layShims(in: target) + let requested = Set(names.map { canonicalize(split($0).name) }) + var queue = names + var seen: Set = [] + var skipped: [String] = [] + while !queue.isEmpty { + let spec = queue.removeFirst() + let (name, pin) = split(spec) + let canonical = canonicalize(name) + guard seen.insert(canonical).inserted else { continue } + // Substitutes come BEFORE the installed check so their adapter is refreshed on + // every ask — an adapter that grows a missing name must reach installs that + // already exist. + if let substitute = substitutes[canonical] { + note("\(canonical) has no pure wheel — installing \(substitute.install) in its place") + queue.append(substitute.install) + if let file = substitute.adapterFile, let source = substitute.adapterSource { + try source.write(to: target.appendingPathComponent(file), + atomically: true, encoding: .utf8) + } + // A dist-info of its own, so "is pyyaml here" answers yes and the closure never + // asks again. + let dist = target.appendingPathComponent("\(canonical)-0.0.0.substituted.dist-info") + try FileManager.default.createDirectory(at: dist, withIntermediateDirectories: true) + try "Metadata-Version: 2.1\nName: \(canonical)\nVersion: 0.0.0.substituted\n" + .write(to: dist.appendingPathComponent("METADATA"), atomically: true, encoding: .utf8) + continue + } + if installed(canonical, in: target) { + note("\(canonical) is already installed") + continue + } + do { + let wheel = try await resolve(canonical, pin: pin) + note("fetching \(canonical) \(wheel.version) (\(wheel.size / 1024) kB)") + let data = try await download(wheel.url) + try ZipArchive.extract(data, to: target) + note("installed \(canonical) \(wheel.version)") + // The wheel's own METADATA names what it needs. Markered requirements (extras, + // other platforms, older pythons) are skipped whole: the one platform this runs + // on is the one no marker anticipates, and an extra is opt-in by definition. + queue.append(contentsOf: try requirements(of: canonical, version: wheel.version, in: target)) + } catch where !requested.contains(canonical) { + skipped.append(canonical) + note("skipped \(canonical): \("\(error)".replacingOccurrences(of: "pip: ", with: ""))") + } + } + if !skipped.isEmpty { + note("skipped \(skipped.count): \(skipped.joined(separator: ", ")) — imports needing them will say so") + } + } + + // MARK: - Substitutes + + /// The Python face of the house substitution pattern (`rollup` -> `@rollup/wasm-node`): + /// a package that only exists compiled, replaced by a pure-published equivalent under the + /// importable name the requester's code actually uses. + /// + /// pyyaml is the one that matters today — hermes's `utils.py` does `import yaml` on its + /// first page — and ruamel.yaml is no stranger standing in: it BEGAN as a PyYAML fork, + /// hermes already pins it, and its author publishes it pure. The adapter is the PyYAML + /// surface callers actually use, expressed as ruamel calls. + private static let substitutes: [String: (install: String, adapterFile: String?, adapterSource: String?)] = [ + "pyyaml": ("ruamel.yaml", "yaml.py", "# pyyaml has no pure-Python wheel, and this Python cannot load compiled extensions.\n# Installed by Mouse's pip as the `yaml` module: PyYAML's common surface over\n# ruamel.yaml (itself a PyYAML fork), which is pure and installed alongside.\nfrom ruamel.yaml import YAML as _YAML\nfrom ruamel.yaml.error import YAMLError # noqa: F401 (PyYAML's name, re-exported)\nimport io as _io\n\ndef _load(stream, typ):\n data = stream.read() if hasattr(stream, \"read\") else stream\n return _YAML(typ=typ, pure=True).load(data)\n\ndef safe_load(stream): return _load(stream, \"safe\")\ndef load(stream, Loader=None): return _load(stream, \"safe\" if Loader is None else \"unsafe\")\ndef full_load(stream): return _load(stream, \"unsafe\")\n\ndef safe_load_all(stream):\n data = stream.read() if hasattr(stream, \"read\") else stream\n return _YAML(typ=\"safe\", pure=True).load_all(data)\n\ndef _dump(data, stream, typ, **kw):\n yml = _YAML(typ=typ, pure=True)\n yml.default_flow_style = kw.get(\"default_flow_style\", False)\n if stream is None:\n out = _io.StringIO()\n yml.dump(data, out)\n return out.getvalue()\n yml.dump(data, stream)\n return None\n\ndef safe_dump(data, stream=None, **kw): return _dump(data, stream, \"safe\", **kw)\ndef dump(data, stream=None, **kw): return _dump(data, stream, \"rt\", **kw)\n\nclass SafeLoader: # noqa: N801 — PyYAML's names, kept for isinstance/subclass users\n pass\nclass Loader(SafeLoader):\n pass\n\nclass SafeDumper: # subclassed in the wild (hermes's IndentDumper); representers are a no-op\n @classmethod\n def add_representer(cls, data_type, representer):\n pass\nclass Dumper(SafeDumper):\n pass\n\ndef add_representer(data_type, representer, Dumper=Dumper):\n pass\n"), + ] + + // MARK: - The registry + + private struct Wheel { + let url: URL + let version: String + let size: Int + } + + /// PyPI's JSON API. A pinned version asks for that release; otherwise the latest. Only a + /// pure wheel (`…-none-any.whl`) is acceptable — anything else needs a compiled extension + /// this Python can never load, and the error says that rather than "not found". + private static func resolve(_ name: String, pin: String?) async throws -> Wheel { + let path = pin.map { "pypi/\(name)/\($0)/json" } ?? "pypi/\(name)/json" + guard let url = URL(string: "https://pypi.org/\(path)") else { + throw PipError("pip: \(name) is not a package name") + } + let data = try await download(url) + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let files = json["urls"] as? [[String: Any]], + let info = json["info"] as? [String: Any], + let version = info["version"] as? String else { + throw PipError("pip: no such package: \(name)" + (pin.map { "==\($0)" } ?? "")) + } + for file in files { + guard let filename = file["filename"] as? String, + filename.hasSuffix("-none-any.whl"), + let location = file["url"] as? String, + let wheelURL = URL(string: location) else { continue } + return Wheel(url: wheelURL, version: version, size: file["size"] as? Int ?? 0) + } + throw PipError("pip: \(name) \(version) has no pure-Python wheel — it needs a compiled " + + "extension, which this Python cannot load") + } + + private static func download(_ url: URL) async throws -> Data { + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw PipError("pip: \(url.host ?? "pypi") answered \(http.statusCode) for \(url.lastPathComponent)") + } + return data + } + + // MARK: - The wheel's own manifest + + /// `Requires-Dist` from the unpacked `*.dist-info/METADATA`, minus anything markered. + private static func requirements(of name: String, version: String, in target: URL) throws -> [String] { + guard let metadata = metadataFile(name, in: target) else { return [] } + let text = try String(contentsOf: metadata, encoding: .utf8) + var wanted: [String] = [] + for line in text.split(separator: "\n") { + guard line.hasPrefix("Requires-Dist:") else { continue } + let requirement = line.dropFirst("Requires-Dist:".count).trimmingCharacters(in: .whitespaces) + guard !requirement.contains(";") else { continue } // markered: extras, other platforms + // "urllib3 (<3,>=1.21.1)" or "idna>=2.5" — the name stops at the first non-name char. + let depName = requirement.prefix { $0.isLetter || $0.isNumber || "-_.".contains($0) } + if !depName.isEmpty { wanted.append(String(depName)) } + } + return wanted + } + + private static func installed(_ name: String, in target: URL) -> Bool { + metadataFile(name, in: target) != nil + } + + /// The dist-info directory a wheel of `name` leaves behind, at any version. Wheel directory + /// names use `_` where the package name has `-`. + private static func metadataFile(_ name: String, in target: URL) -> URL? { + let stem = name.replacingOccurrences(of: "-", with: "_").lowercased() + let entries = (try? FileManager.default.contentsOfDirectory(atPath: target.path)) ?? [] + for entry in entries where entry.lowercased().hasPrefix(stem + "-") && entry.hasSuffix(".dist-info") { + let file = target.appendingPathComponent(entry).appendingPathComponent("METADATA") + if FileManager.default.fileExists(atPath: file.path) { return file } + } + return nil + } + + // MARK: - Names + + /// PEP 503: comparisons happen on the lowercased name with runs of `-`, `_`, `.` as one `-`. + static func canonicalize(_ name: String) -> String { + var out = "" + var dash = false + for character in name.lowercased() { + if "-_.".contains(character) { + dash = true + } else { + if dash, !out.isEmpty { out.append("-") } + dash = false + out.append(character) + } + } + return out + } + + /// `name==1.2.3` → (name, pin). Other operators are refused rather than misread: this + /// installer resolves exact pins and latest, and pretending `>=` resolved would install + /// something the requester did not ask for. + static func split(_ spec: String) -> (name: String, pin: String?) { + if let range = spec.range(of: "==") { + return (String(spec[..[==version] …` — pure-Python wheels only, straight from PyPI into + /// the runtime's site-packages. See `Pip` for why that subset is the honest one here. + private func pipCmd(_ args: [String], context: Context) async -> IO { + guard args.first == "install", args.count >= 2 else { + return IO(err: "pip: usage: pip install [==version] …\n", status: 2) + } + guard RuntimeStore.installed("python") != nil else { + return IO(err: "pip: python is not installed — `pkg install python`\n", status: 1) + } + // Notes cross from the installer's task to the shell's context through a stream — + // `Context` is actor-bound and must not be captured in a @Sendable closure. + let (stream, continuation) = AsyncStream.makeStream(of: String.self) + let specs = Array(args.dropFirst()) + let installer = Task { + defer { continuation.finish() } + try await Pip.install(specs) { continuation.yield($0) } + } + for await line in stream { + context.emit(Output(text: line, isError: false)) + } + do { + try await installer.value + } catch { + return IO(err: "\(error)\n", status: 1) + } + return IO() } private func pkgCmd(_ args: [String], context: Context) async -> IO { @@ -1724,7 +1759,7 @@ final class MouseShell { static let builtinNames: Set = [ "help", "clear", "pwd", "cd", "ls", "cat", "echo", "printf", "mkdir", "touch", "rm", "mv", "cp", "head", "tail", "wc", "sort", "uniq", "tr", "cut", "seq", "grep", "find", - "date", "whoami", "true", "false", "env", "export", "unset", "history", "which", + "date", "whoami", "true", "false", "env", "export", "unset", "history", "which", "pip", "basename", "dirname", "open", "sleep", "ping", "curl", "wget", "tee", "xargs", "rev", "tac", "nl", "base64", "md5sum", "md5", "sha256sum", "shasum", "sed", "diff", "git", "less", "more", "nano", "vi", "vim", "uname", "lsb_release", "df", "free", diff --git a/swift/Mouse/StripPersistence.swift b/swift/Mouse/StripPersistence.swift index 95ccbcf..9c30b19 100644 --- a/swift/Mouse/StripPersistence.swift +++ b/swift/Mouse/StripPersistence.swift @@ -106,6 +106,11 @@ extension CarouselDeck { lanes[index].current = reserve.removeFirst() } lanes.removeAll { retired($0.current) } + // A container added to the catalog AFTER this ring was saved exists in no snapshot, so + // without this an existing install would never see it — the Agent container arrived that + // way. Anything catalogued but absent joins the reserve, where the next swipe reaches it. + let present = Set(lanes.map { $0.current.kind } + reserve.map { $0.kind }) + reserve.append(contentsOf: ContainerType.catalog().filter { !present.contains($0.kind) }) if lanes.isEmpty { lanes = [Lane(current: reserve.isEmpty ? ContainerType.entry(kind: ContainerType.gitHubKind) : reserve.removeFirst())] diff --git a/swift/Mouse/Terminal.swift b/swift/Mouse/Terminal.swift index 662e411..993fa97 100644 --- a/swift/Mouse/Terminal.swift +++ b/swift/Mouse/Terminal.swift @@ -148,8 +148,9 @@ enum TerminalCellMetrics { /// The SCREEN renderer: rows of styled cells while a program owns the terminal. Redraws are /// driven by `screenGeneration` (the grid itself is a Foundation engine, not observable). /// No gestures of its own — the gesture law: content gets taps and the keyboard, the shell -/// keeps the drags. -private struct TerminalScreenGrid: View { +/// keeps the drags. Internal, not private: the Agent container embeds it when an agent's own +/// sign-in flow (a full-screen program) has to run inside the chat. +struct TerminalScreenGrid: View { let terminal: TerminalSession var body: some View { diff --git a/swift/Mouse/TerminalSession.swift b/swift/Mouse/TerminalSession.swift index a6eb49e..d04fc34 100644 --- a/swift/Mouse/TerminalSession.swift +++ b/swift/Mouse/TerminalSession.swift @@ -122,14 +122,17 @@ final class TerminalSession { /// Returns false when the input was refused (a command is already running) so the prompt /// field can keep its text. + /// `screenless`: the caller has no grid to give a full-screen program. The Agent container + /// is the case — it renders a conversation, not a terminal — and without this a print-mode + /// invocation like `claude -p` is handed the screen it never asked for and never returns. @discardableResult - func run(_ raw: String, hooks: Hooks = Hooks()) -> Bool { + func run(_ raw: String, hooks: Hooks = Hooks(), screenless: Bool = false) -> Bool { guard !isRunning, program == nil else { return false } let command = raw.trimmingCharacters(in: .whitespaces) append("\(prompt) \(command)", .command) guard !command.isEmpty else { return true } switch engine { - case .msh: runShell(command, hooks: hooks) + case .msh: runShell(command, hooks: hooks, screenless: screenless) case .js: runJavaScript(command) } return true @@ -299,7 +302,15 @@ final class TerminalSession { screenGeneration += 1 } - private func runShell(_ command: String, hooks: Hooks) { + private func runShell(_ command: String, hooks: Hooks, screenless: Bool = false) { + // No launcher means no screen to take: `runNode` sees `launchProgram == nil` and runs + // the bin the way that RETURNS its output, which is what a caller without a grid can + // actually use. Spelled out rather than inlined — a ternary over an optional closure + // gives the type checker nothing to work with. + var launcher: (@MainActor @Sendable (any TerminalProgram) -> Void)? + if !screenless { + launcher = { [weak self] program in self?.launch(program) ?? () } + } let context = MouseShell.Context( root: root, markModified: { hooks.markModified($0) }, @@ -312,7 +323,7 @@ final class TerminalSession { historyChanged: { hooks.historyChanged() }, githubToken: { hooks.githubToken() }, githubLogin: { hooks.githubLogin() }, - launchProgram: { [weak self] program in self?.launch(program) } + launchProgram: launcher ) isRunning = true runningTask = Task { @MainActor [weak self] in diff --git a/swift/Mouse/ThinkingOrb.swift b/swift/Mouse/ThinkingOrb.swift new file mode 100644 index 0000000..daa97f8 --- /dev/null +++ b/swift/Mouse/ThinkingOrb.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// The little sphere of dots that turns while the agent is doing something. +/// +/// After the `thinking-orbs` component by Jakub Antalik and Alex Brinza +/// (orbs.jakubantalik.com) — the idea and the visual language are theirs. That component is +/// React on npm and cannot be imported here, so this is the same thing built natively: points +/// on a sphere, rotated and projected each frame, drawn in one `Canvas`. +/// +/// Monochrome on purpose. Every other surface in this app is white on black in one mono face, +/// and a colour gradient here would be the only thing in the ring shouting. +struct ThinkingOrb: View { + enum State { + /// Nothing happening — the orb rests, barely turning. + case idle + /// The microphone is open. + case listening + /// The agent is working. + case working + + /// Turns per second. + var speed: Double { + switch self { + case .idle: return 0.08 + case .listening: return 0.35 + case .working: return 0.55 + } + } + + /// How far the sphere breathes, as a fraction of its radius. + var breath: Double { + switch self { + case .idle: return 0.02 + case .listening: return 0.10 + case .working: return 0.05 + } + } + } + + var state: State = .idle + var size: CGFloat = 18 + + /// Points on the sphere, once. A Fibonacci lattice spaces them evenly, which a naive + /// lat/long grid does not — that bunches everything at the poles and reads as two bright + /// caps with a bald equator. + private static let points: [SIMD3] = { + let count = 96 + let golden = Double.pi * (3 - (5.0).squareRoot()) + return (0..` on every deployment, including the loopback +// bind, with no way to disable it. What matters here is that we send exactly that — a client that +// quietly drops the header works against nothing, and one that posts to the wrong path fails in a +// way that looks like the server is down. +// +// A stand-in server rather than the real Hermes: this asserts OUR half. The previous version of +// this gate proved a client against a stub built from the same wrong guess as the client, so the +// shape here is taken from the published docs rather than from the code under test. + +let port = 8644 +var failures = 0 +func check(_ condition: Bool, _ label: String) { + if !condition { failures += 1; print(" FAIL: \(label)") } +} + +let script = """ +import json, sys +from http.server import BaseHTTPRequestHandler, HTTPServer +class H(BaseHTTPRequestHandler): + def log_message(self, *a): pass + def do_POST(self): + n = int(self.headers.get("content-length", 0)) + body = json.loads(self.rfile.read(n) or b"{}") + auth = self.headers.get("Authorization", "") + sys.stderr.write("PATH %s AUTH %s MODEL %s N %d\\n" % + (self.path, auth, body.get("model"), len(body.get("messages", [])))) + sys.stderr.flush() + if auth != "Bearer right-key": + out = b'{"error":{"message":"invalid api key"}}' + self.send_response(401) + elif body["messages"][-1]["content"] == "break": + out = b'not json at all' + self.send_response(200) + else: + out = json.dumps({"choices":[{"message":{"role":"assistant", + "content":"echo: " + body["messages"][-1]["content"]}}]}).encode() + self.send_response(200) + self.send_header("content-type","application/json") + self.send_header("content-length", str(len(out))) + self.end_headers(); self.wfile.write(out) +print("ready", flush=True) +HTTPServer(("127.0.0.1", \(port)), H).serve_forever() +""" +let scriptURL = FileManager.default.temporaryDirectory + .appendingPathComponent("agentapi-\(getpid()).py") +try? script.write(to: scriptURL, atomically: true, encoding: .utf8) +defer { try? FileManager.default.removeItem(at: scriptURL) } +let server = Process() +server.executableURL = URL(fileURLWithPath: "/usr/bin/env") +server.arguments = ["python3", scriptURL.path] +let ready = Pipe() +server.standardOutput = ready +server.standardError = Pipe() +try? server.run() +defer { server.terminate() } +_ = ready.fileHandleForReading.availableData + +// The address the container types, and the documented default when it types nothing. +check(AgentAPI(address: "", key: "k")?.baseURL.absoluteString == "http://127.0.0.1:8642", + "an empty address is the documented default") +check(AgentAPI(address: "10.0.0.5:9000", key: "k")?.baseURL.absoluteString == "http://10.0.0.5:9000", + "host:port gets a scheme") +check(AgentAPI(address: "https://box.local:443", key: "k")?.baseURL.scheme == "https", + "a scheme already there is kept") +check(AgentAPI(address: "http://", key: "k") == nil, "a hostless address is refused") +check(AgentAPI(address: "", key: "k")?.model == "hermes-agent", "the default profile's model name") + +let api = AgentAPI(address: "127.0.0.1:\(port)", key: "right-key")! +do { + let reply = try await api.complete([("user", "hello")]) + check(reply == "echo: hello", "the answer comes out of choices[0].message.content: \(reply)") + let threaded = try await api.complete([("user", "one"), ("assistant", "two"), ("user", "three")]) + check(threaded == "echo: three", "the whole conversation goes up, newest last") +} catch { + failures += 1 + print(" FAIL: a good call threw: \(error)") +} + +// A wrong key must say so. This is the failure a user will actually hit. +do { + _ = try await AgentAPI(address: "127.0.0.1:\(port)", key: "wrong")!.complete([("user", "hi")]) + failures += 1 + print(" FAIL: a rejected key should not look like success") +} catch { + check("\(error)".contains("401"), "a rejected key reports 401: \(error)") +} + +// A body that is not the expected shape is not an answer. +do { + _ = try await api.complete([("user", "break")]) + failures += 1 + print(" FAIL: unparseable output should not be returned as an answer") +} catch { + check("\(error)".contains("made no sense"), "a malformed body says so: \(error)") +} + +// Nothing listening: report, do not hang. +do { + _ = try await AgentAPI(address: "127.0.0.1:9", key: "k")!.complete([("user", "hi")]) + failures += 1 + print(" FAIL: a closed port should not answer") +} catch { + check("\(error)".contains("cannot reach"), "a closed port reports why: \(error)") +} + +if failures == 0 { + print("AGENT API: the documented request — path, bearer, OpenAI body — plus a rejected key, a malformed answer and a closed port — MATCH") +} else { + print("AGENT API: \(failures) checks failed — MISMATCH") + exit(1) +} diff --git a/verify/build-one.sh b/verify/build-one.sh index b422969..2a9dd63 100755 --- a/verify/build-one.sh +++ b/verify/build-one.sh @@ -10,14 +10,20 @@ M="$(cd "$T/../swift/Mouse" && pwd)" NODE_SET="$M/NodeEngine.swift $M/NodeSockets.swift $M/NodeWatch.swift $M/NodeKeys.swift $M/NodeScrypt.swift $M/NodeBrotli.swift $M/NodeDNS.swift $M/PackageManager.swift" TERM_SET="$NODE_SET $M/TerminalScreen.swift $M/TerminalWidth.swift $M/TerminalPrograms.swift" # msh installs language runtimes (`pkg install python`), so the shell set carries them. -SHELL_SET="$M/Shell.swift $M/ShellLanguage.swift $M/GitCore.swift $M/GitRemote.swift $M/Runtimes.swift $TERM_SET" +SHELL_SET="$M/Shell.swift $M/ShellLanguage.swift $M/GitCore.swift $M/GitRemote.swift $M/Runtimes.swift $M/PipInstaller.swift $TERM_SET" # The terminal SESSION — scrollback, engines, the run/interrupt path — without its SwiftUI views. SESSION_SET="$SHELL_SET $M/TerminalSession.swift" name="$1"; dir="$T/$name" # Pick the source set from what the harness actually REFERENCES, not from its name. Keying on # names meant every new terminal or shell harness failed to build until someone remembered to # add it here — which is a verification gap wearing the costume of a typo. -if grep -qE 'TerminalSession' "$dir/main.swift" 2>/dev/null; then +# The agent container's API client stands alone — one HTTP call, no engine behind it. +if grep -qE 'AgentAPI' "$dir/main.swift" 2>/dev/null; then + SRC="$M/AgentAPI.swift" +# The wheel installer needs the zip reader (Runtimes) and TarGz (PackageManager) behind it. +elif grep -qE 'Pip\.' "$dir/main.swift" 2>/dev/null; then + SRC="$M/PipInstaller.swift $M/Runtimes.swift $NODE_SET" +elif grep -qE 'TerminalSession' "$dir/main.swift" 2>/dev/null; then SRC="$SESSION_SET" elif grep -qE 'Shell\(|ShellLanguage|GitCore|GitRemote' main_probe 2>/dev/null || \ grep -qE 'Shell\(|ShellLanguage|GitCore|GitRemote' "$dir/main.swift" 2>/dev/null; then diff --git a/verify/pipwheel/main.swift b/verify/pipwheel/main.swift new file mode 100644 index 0000000..9778416 --- /dev/null +++ b/verify/pipwheel/main.swift @@ -0,0 +1,70 @@ +import Foundation +setvbuf(stdout, nil, _IONBF, 0) + +// `pip install` against the REAL PyPI — pure wheels only, which is the whole contract. +// +// The on-device CPython has no pip, no ensurepip, and cannot load a compiled extension, so this +// installer exists to put pure-Python wheels where PYTHONPATH finds them and to refuse compiled +// ones in words. Real registry rather than a stub: the previous stub-shaped gate in this area +// ended up agreeing with the client about the wrong protocol, and PyPI's JSON shape is the thing +// half these checks assert. + +var failures = 0 +func check(_ condition: Bool, _ label: String) { + if !condition { failures += 1; print(" FAIL: \(label)") } +} + +// Name rules stand alone. +check(Pip.canonicalize("Ruamel.YAML") == "ruamel-yaml", "PEP 503: dots and case fold") +check(Pip.canonicalize("prompt__toolkit") == "prompt-toolkit", "runs of separators are one dash") +check(Pip.split("python-dotenv==1.2.2").pin == "1.2.2", "an exact pin parses") +check(Pip.split("httpx").pin == nil, "a bare name has no pin") + +let target = FileManager.default.temporaryDirectory + .appendingPathComponent("pipwheel-\(ProcessInfo.processInfo.processIdentifier)") +defer { try? FileManager.default.removeItem(at: target) } + +func note(_ line: String) { print(" \(line)") } + +// 1. A pinned, dependency-free wheel: the exact version lands and imports would find it. +do { + try await Pip.install(["python-dotenv==1.2.2"], into: target, note: note) + let module = target.appendingPathComponent("dotenv/__init__.py") + check(FileManager.default.fileExists(atPath: module.path), "dotenv/__init__.py landed") + let dist = target.appendingPathComponent("python_dotenv-1.2.2.dist-info/METADATA") + check(FileManager.default.fileExists(atPath: dist.path), "the pinned version is the one installed") +} catch { failures += 1; print(" FAIL: dotenv install threw: \(error)") } + +// 2. The closure: requests pulls charset-normalizer, idna, urllib3, certifi by itself. +do { + try await Pip.install(["requests"], into: target, note: note) + for dep in ["requests", "idna", "urllib3", "certifi", "charset_normalizer"] { + let present = FileManager.default.fileExists(atPath: target.appendingPathComponent(dep).path) + || FileManager.default.fileExists(atPath: target.appendingPathComponent(dep + ".py").path) + check(present, "\(dep) arrived as part of requests' closure") + } +} catch { failures += 1; print(" FAIL: requests install threw: \(error)") } + +// 3. Idempotence: asking again is a statement, not a re-download. +do { + var said = "" + try await Pip.install(["requests"], into: target) { said += $0 } + check(said.contains("already installed"), "a second install says already installed") +} catch { failures += 1; print(" FAIL: re-install threw: \(error)") } + +// 4. A compiled-only package is refused IN WORDS. pydantic-core is the exact wall Hermes hits. +do { + _ = try await Pip.install(["pydantic-core"], into: target, note: note) + failures += 1 + print(" FAIL: pydantic-core should have been refused — it has no pure wheel") +} catch { + check("\(error)".contains("no pure-Python wheel"), + "the refusal names the reason: \(error)") +} + +if failures == 0 { + print("PIP WHEEL: pins, closures, idempotence and an honest refusal, against the real PyPI — MATCH") +} else { + print("PIP WHEEL: \(failures) checks failed — MISMATCH") + exit(1) +} diff --git a/verify/pkgpython/main.swift b/verify/pkgpython/main.swift index 4636338..8343218 100644 --- a/verify/pkgpython/main.swift +++ b/verify/pkgpython/main.swift @@ -53,9 +53,12 @@ setvbuf(stdout, nil, _IONBF, 0) let attributes = try? manager.attributesOfItem(atPath: python.wasm.path) let size = (attributes?[.size] as? Int) ?? 0 check(size > 20_000_000, "python.wasm is \(size) bytes, which is far too small to be CPython") - let encodings = python.directory.appendingPathComponent("lib/python3.14/encodings/__init__.py") - check(manager.fileExists(atPath: encodings.path), - "the standard library did not unpack — \(encodings.lastPathComponent) is missing") + // This build ships its standard library as python312.zip and imports it through + // zipimport — which is exactly why it compiles zlib in, and zlib is why the build was + // chosen. A loose encodings/ directory does not exist and should not be looked for. + let stdlib = python.directory.appendingPathComponent("usr/local/lib/python312.zip") + check(manager.fileExists(atPath: stdlib.path), + "the standard library did not unpack — python312.zip is missing") } let second = await msh("pkg install python") @@ -68,7 +71,7 @@ setvbuf(stdout, nil, _IONBF, 0) .write(to: hello, atomically: true, encoding: .utf8) let ran = await msh("python hello.py") check(ran.contains("hello from python"), "`python hello.py` did not print what the script prints: [\(ran)]") - check(ran.contains("3.14.6"), "the interpreter did not report its version: [\(ran)]") + check(ran.contains("3.12.0"), "the interpreter did not report its version: [\(ran)]") // `-c`, the other form everyone uses. let inline = await msh("python -c 'print(6*7)'") @@ -97,11 +100,11 @@ setvbuf(stdout, nil, _IONBF, 0) if problems.isEmpty { print("PKG PYTHON MATCH — the whole path works through msh: an uninstalled runtime " - + "refuses by naming its install command, `pkg install python` downloads 14 MB, " + + "refuses by naming its install command, `pkg install python` downloads 11 MB, " + "verifies it against a recorded hash, unpacks it with the zip reader written for " + "this (iOS has no unzip) and reports what it did; a second install notices the " + "first; `python hello.py`, `python -c` and a script reading a project file all " - + "run CPython 3.14.6 through the engine's WASI; a traceback reaches the terminal; " + + "run CPython 3.12.0 through the engine's WASI; a traceback reaches the terminal; " + "and `pkg remove` removes it") } else { for problem in problems { print(" \(problem)") } diff --git a/verify/scopedbin/main.swift b/verify/scopedbin/main.swift new file mode 100644 index 0000000..2f731df --- /dev/null +++ b/verify/scopedbin/main.swift @@ -0,0 +1,45 @@ +import Foundation +setvbuf(stdout, nil, _IONBF, 0) + +// WHERE A PLACEMENT SITS, which decides whether its bins become commands. +// +// The rule is "no FURTHER node_modules below the first", and it was written as "no slash after +// node_modules/". Those agree for `chalk` and disagree for every SCOPED package, because +// `@scope/name` has a slash in the name itself. So no scoped CLI ever became a command: +// `npm i -g @anthropic-ai/claude-code` answered "added 1 packages" and left no `claude` behind. +// +// Path shapes rather than a download: this is a rule about strings, the registry cannot make it +// truer, and a gate that pulls ten megabytes to assert one boolean earns nothing. + +var failures = 0 +func check(_ condition: Bool, _ label: String) { + if !condition { failures += 1; print(" FAIL: \(label)") } +} + +func placement(_ path: String) -> PackageManager.Placement { + PackageManager.Placement( + package: PackageManager.ResolvedPackage( + name: "x", version: "1.0.0", tarball: "", integrity: nil, shasum: nil, + dependencies: [:], optionalDependencies: [:], bin: ["x": "cli.js"]), + path: path) +} + +let cases: [(path: String, atRoot: Bool, why: String)] = [ + ("node_modules/chalk", true, "a plain top-level package"), + ("node_modules/@anthropic-ai/claude-code", true, "a SCOPED top-level package — the bug"), + ("node_modules/@rollup/wasm-node", true, "the substitution this app relies on is scoped too"), + ("node_modules/chalk/node_modules/supports-color", false, "genuinely nested"), + ("node_modules/a/node_modules/@scope/b", false, "nested AND scoped"), + ("node_modules/@scope/a/node_modules/b", false, "nested under a scoped parent"), +] +for item in cases { + check(placement(item.path).atRoot == item.atRoot, + "\(item.path) should\(item.atRoot ? "" : " not") be top level — \(item.why)") +} + +if failures == 0 { + print("SCOPED BIN: \(cases.count) placement shapes, scoped packages included — MATCH") +} else { + print("SCOPED BIN: \(failures) of \(cases.count) failed — MISMATCH") + exit(1) +}