Skip to content

Agent container: Claude Code and Hermes behind the chat - #21

Open
ThyFriendlyFox wants to merge 49 commits into
mainfrom
cursor-features
Open

Agent container: Claude Code and Hermes behind the chat#21
ThyFriendlyFox wants to merge 49 commits into
mainfrom
cursor-features

Conversation

@ThyFriendlyFox

Copy link
Copy Markdown
Collaborator

What this adds

A sixth container — Agent (kind 16) — a chat that drives a coding agent on the ring's open project, with a picker holding the two agents that can genuinely run on the phone today:

  • Claude Code 2.1.98, running as itself on the app's Node engine: installed by the app's own npm, -p per turn, streaming from the API. Verified answering in the container on the simulator.
  • Hermes Agent 0.19.0, embedded per the architecture decision: its real Python loop runs on on-device wasi CPython, and Mouse is its tool surface — model calls on URLSession's TLS, shell on msh, files on the workspace. Verified answering in the container on the simulator.
  • Oh My Pi is deliberately absent (Bun/native — iOS will not execute it); the catalog says so instead of hiding it.

Auth, the way the agents document it

  • Claude Code's own sign-in runs inside the chat: a sign in row hosts claude setup-token (its real ink screen) on an embedded terminal grid; a chip reassembles the hard-wrapped OAuth URL into a tappable link (Safari lands on Anthropic's login), the chat input feeds the program the pasted code, and stop reclaims the terminal in one tap. The API-key field remains the other way in.
  • One home for every agent: the shell mounts a shared /home, os.homedir() honors $HOME like real node, and the Agent container exports HOME=/home — sign in once, every project has it, while cwd stays the project. Measured: sign-in writes only to the shared home.
  • Keys go to the keychain (device-only), never UserDefaults.

Voice and the orb

On-device dictation (SFSpeechRecognizer, requiresOnDeviceRecognition) fills the field rather than sending. The thinking orb (after orbs.jakubantalik.com, credited in the header) is the mic button itself and the working indicator — idle / listening / working are one element, not three.

Engine work it took (all measured, gated in verify/)

  • Claude Code 1.0.128 is dead upstream (statsig.anthropic.com is NXDOMAIN; hangs on real node too) — the pin moves to 2.1.98, the newest all-JavaScript release.
  • Five engine fixes to run it: ESM transpiler ICU guard on multi-MB sources, stream/consumers + stream/web core modules, Symbol.dispose/asyncDispose polyfill before timers attach, POSIX rename(2) for atomic replace, and the spawnSync guard relaxed to refuse only input (its refusal of timeout was the silent startup death).
  • Interrupt ledger: engine interrupts now name what they were waiting on (timers, labeled http streams), and a busy report separates computing from spinning.
  • Scoped npm bins register (verify/scopedbin); pip installs pure wheels from PyPI with substitutes and stdlib shims for wasi (verify/pipwheel); Python runtime moved to VMware Labs CPython 3.12.0 wasi (has zlib).
  • Setup fields commit on blur as well as return, and settings reads subscribe views through the store's version counter — a saved key hides its field immediately.

For a reviewer

  • The gesture law and focused-editor stand-down are untouched; the Agent container takes taps and vertical scroll only.
  • AgentSession is the seam: embedded agents step through a file bridge (.hermes-bridge/turn.jsonout.json); CLI agents run screenless one-shots on the workspace's terminal session. The sign-in path is the one place a full-screen program runs inside the chat, on the Terminal container's grid renderer (opened from private to internal).
  • What remains user-only: completing the real OAuth (an account owner pasting a real code) — every step around it is verified on the simulator, including the invalid-code round trip.
  • STATUS.md and .claude/agent-brief.md carry the evidence trail per phase.

🤖 Generated with Claude Code

ThyFriendlyFox and others added 30 commits August 13, 2026 20:15
The reference is the Cursor pane in the screenshot: the exchange scrolls above,
a follow-up field with a microphone sits at the bottom, and the status line
under it carries the workspace on the left and the agent picker on the right.
This is that, in this app's terms — vertical scroll and taps only, so the
horizontal drag stays the shell's.

Nothing is bundled. system.md's rule is that runtimes are INSTALLED and the
acceptance test is each agent's own install command verbatim, so the catalog is
what ships — which agents exist, what runtime each needs, the install line from
its own documentation, and how to start it. `AgentSession` runs those through a
`TerminalSession` on the workspace: the same msh, the same npm, the same Node
the Terminal container uses.

One-shot prompts, not an interactive session. Every agent here has a print mode
(`claude -p "…"`) that answers and exits, and that mode needs no ANSI screen.
Hosting the interactive TUI is the larger gap system.md names, and it belongs
with the phase-T screen the Terminal container already owns rather than being
faked here.

OH MY PI IS NOT IN THE PICKER. `omp` is a Bun CLI with Rust native bindings, and
Bun is a native binary — iOS will not execute one. It is the same wall that
stops opencode's Go TUI and current claude-code's `claude.exe`, reached from a
third direction. Claude Code is pinned at 1.0.128 for exactly that reason: the
last line that is JavaScript the whole way down, and the one STATUS.md records
running on the phone.

Voice is on-device. `requiresOnDeviceRecognition` is set rather than preferred,
because the alternative uploads recorded audio of whatever is said near the
phone, and a coding prompt is the user's own source. The microphone fills the
field instead of sending: dictation misreads identifiers, and a prompt you
cannot correct before it runs is worse than typing it.

The container is kind 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 have restored a dead placeholder wearing
the Agent's name. A kind number is an identity in persisted data.

Restoring a ring now also adds any container catalogued since it was saved.
Without that an existing install would never see this one: the reserve is
persisted, so a new catalog entry reaches nobody who already has a ring.

Verified on the simulator, driven through the app: the container appears in the
ring after Terminal; the status line reads `no project` and `Claude Code v`; the
picker opens on both entries with their runtimes (`node`, `python`) and a dot on
the chosen one; tapping Hermes Agent switches the status line to
`Hermes Agent v`. Info.plist carries both usage strings — the microphone one is
asserted here because the first attempt put them a level above `properties:` in
project.yml, where xcodegen silently ignored them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
THE ORB. After the `thinking-orbs` component by Jakub Antalik and Alex Brinza
(orbs.jakubantalik.com) — the idea and the visual language are theirs. It ships
as a React package on npm, which cannot be imported into SwiftUI, so this is the
same thing built natively: ninety-six points on a Fibonacci lattice, rotated and
projected every frame in one `Canvas`. A lattice rather than a lat/long grid
because the naive grid bunches its points at the poles and reads as two bright
caps with a bald equator. Depth drives size and opacity, which is what makes it
a ball instead of a disc, and a fixed tilt keeps the poles visible so the spin
reads as rotation.

Monochrome, deliberately. Every surface in this ring is white on black in one
mono face, and a colour gradient here would be the only thing shouting.

Three states, as the reference has: idle turns barely at all, listening breathes
with the microphone open, working spins. The container shows the orb where it
had a bare `…`, and the empty container now says `ask hermes agent` beside a
resting orb rather than being a black rectangle with a text field at the bottom.
It also gained a header — `hermes agent on <project>` — matching the Graph and
Files containers, which frees the status line to be the picker alone.

HERMES IS A TUI, AND THE ANSWER IS ITS OWN GATEWAY. The previous commit had it
launching `hermes -p`, borrowing Claude Code's print mode. Hermes has no such
mode: it is an interactive terminal app. But it does not need one, because it
already solved this — `tui_gateway/` is how Hermes talks to front-ends that are
not a terminal, and the Telegram bot is one of those front-ends. It runs as
`python -m tui_gateway.entry` and speaks newline-delimited JSON over stdio,
`{"id": …, "command": …}` in and events out, with a WebSocket sidecar for
dashboards. A protocol, not a screen. The catalog now names that.

WHAT IS NOT DONE, so the next commit knows where to start: the container does
not yet SPEAK that protocol. It runs the launch command and reads the terminal
tail, which suits Claude Code's print mode and does not suit Hermes — mapping
gateway events onto messages is the real work, and it is the whole of Hermes
being usable here.

Verified on the simulator, driven through the app: the container renders the
header, the resting orb with its label, the input with microphone and send, and
`Hermes Agent v` on the status line; the choice persisted across a reinstall.
The orb draws correctly as a shaded sphere. Its motion is NOT proven here —
still screenshots cannot show it, and idle is deliberately the slowest state at
0.08 turns per second. Neither listening nor working was exercised: one needs
microphone permission, the other needs an open project.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two things the reference already implied. The orb has a listening state and sits
exactly where a microphone button goes, so a separate `mic` glyph beside it was
two things saying one thing — tapping the orb starts dictation and it picks up.
The pill that also said "agent listening…" is gone with it: one spinning orb,
not two. And the picker is the agent's name alone; the `v` was decoration on a
line that is already a button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The npm-dev loop had reached iteration 81 with no completion promise, which is
why it could never stop — it was still re-feeding a goal that shipped in v1.4.
This one carries a promise: both agents answering a typed prompt in the
container, each with a screenshot.

The brief records what the user's own screenshot showed — `pip install
hermes-agent` then `(no output)` — the three candidates behind it, the
tui_gateway protocol that is the real work, and the testing facts that cost an
iteration each last time (edge swipe skips onboarding, clean derivedDataPath,
xcodegen after a new file).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The container said `(no output)` over a plain failure, so the first job was to
stop it lying. `run` waited only on `isRunning`, which a full-screen `program`
leaves false — the wait ended at once and the NEXT command was refused, printing
nothing. And it counted any new line as success, so `msh: command not found:
pip` passed as a successful install and the launch went ahead into the dark. It
now waits for the program too, treats an error line as failure, and shows the
command's own words: "command not found: pip" says what to do next, "Hermes
Agent did not install" does not.

Then the measurements, through msh on the real engine:

    python --version        python: not installed — `pkg install python`
    pkg install python      installed python 3.14.6
    python -m pip --version         No module named pip
    python -m ensurepip --version   No module named ensurepip
    pip --version           msh: command not found: pip
    npm install -g left-pad                        added 1 packages
    npm install -g @anthropic-ai/claude-code@1.0.128   added 1 packages
    claude --version        msh: command not found: claude

So the two agents fail for entirely different reasons, and neither is the one I
assumed.

HERMES CANNOT BE INSTALLED HERE AT ALL. The CPython wasi build has no pip and no
ensurepip, so no Python package can be installed on this device, and
hermes-agent's native dependencies would be the next wall behind that. Marked
blocked with that reason rather than left to fail at the prompt. The way in is
the one Telegram already uses: Hermes runs on a machine and the chat front-end
is a CLIENT of its gateway. That client is a network client this container can
be, and it is the next thing to build.

CLAUDE CODE INSTALLS AND IS THEN UNREACHABLE. `npm install -g` works — "added 1
packages" — but `claude` is not found afterwards, which is the `$PATH` gap
phase E already names as missing. Nothing about the agent; the shell cannot
find a globally-installed bin. That is one concrete fix and it is what stands
between this container and Claude Code answering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`npm i -g @anthropic-ai/claude-code@1.0.128` answered "added 1 packages" and
then `claude` was "command not found". The install was fine; the command was
never registered, and neither was any other scoped package's.

A placement's bins become commands only when it sits at the top of
node_modules, and that test read "nothing after `node_modules/` contains a
slash". A scoped package lives at `node_modules/@scope/name`, so the slash is in
its NAME — every scoped package looked nested and had its bins dropped. The rule
is really "no FURTHER node_modules", which is what it says now.

Measured on the real registry, same install both sides:

    before   placements: 1   report.bins: [:]      atRoot=false   bin=["claude": "cli.js"]
    after    added 1 packages / bin: claude        atRoot=true

`@rollup/wasm-node` is scoped too, which is the substitution vite's whole build
rests on — it was installing under the name `rollup`, so its own bins were
missing for the same reason and nobody had noticed.

Gate: `verify/scopedbin` asserts six placement shapes — plain, scoped, nested,
nested-and-scoped, nested-under-scoped. Against the old rule two fail, both of
them the scoped top-level cases.

Where this leaves the goal: `claude` now resolves and STARTS. It does not
finish — `claude --version` and `claude -p 'say hi'` both hold the terminal as a
program for 90 seconds with no output, so 1.0.128 is taking the screen rather
than printing and exiting. That is the next thing, and `AgentSession.run` will
need a bound on the wait before it can be trusted with an agent that hangs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`claude -p 'say hi'` does not print and exit. msh launches an installed bin
interactively, which makes it a full-screen program that owns the terminal until
it chooses to leave, and 1.0.128 was still holding it after ninety seconds with
nothing printed. The container waited on that with no bound, so a hung agent
meant a spinner with no way to say why.

There is now a deadline — three minutes, because installing an agent is a real
download — after which the run interrupts the program and answers with whatever
it printed, or says plainly that it is still running and printed nothing.

What the hang almost certainly is: `-p` needs credentials, and without them the
CLI is waiting for a login it can never get here. msh has `export NAME=value`,
so the credential can reach it the ordinary way; collecting and saving one is
the setup step this container still owes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both agents need something before they can answer, and asking again on every
launch is a container nobody opens twice — which is why the current Hermes grew
savable profiles. This is that, one profile per agent: Claude Code needs
ANTHROPIC_API_KEY, Hermes needs the address of the machine running its gateway.

The key goes to the KEYCHAIN, accessible only when the phone is unlocked and
only on this device. An API key in UserDefaults is readable by anything that can
read the container's files, a backup of the phone included. The gateway address
is not a secret and lives in UserDefaults.

The field shows only while its setting is empty, saves on submit, and does not
come back. `export NAME=value` puts it where the agent's own CLI looks, once per
session — repeating it would print the key into the transcript a second time.
Sending with the setting still empty says so instead of launching an agent that
will sit waiting for a login it cannot get.

Verified on the simulator: edge swipe past onboarding, five swipes to the
container, and with Hermes selected the row reads `HERMES_GATEWAY host:port`
above the input. The two earlier changes are on screen with it — the orb is the
microphone button, and the status line is `Hermes Agent` with no caret.

Still not the goal: neither agent has answered. Claude Code needs a key I do not
have, and Hermes needs a gateway to point at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Its three open questions are all answered and all fixed — no pip on the device,
the reporting that called a failure success, and the scoped bins that were never
registered. It says so now, so the next iteration does not re-derive them.

Added: the gateway client design, and the incoherence it resolves. The container
asks for HERMES_GATEWAY and then refuses to use it, because Hermes is marked
blocked for having no local install — but the address is exactly what makes it
not blocked. Verify that client against a stub before asking anyone to run the
real thing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last commit put a `HERMES_GATEWAY host:port` field under an agent that is
marked blocked, so the container collected an address and then refused to use
it. A field for a value the app ignores is worse than no field: it reads as
"configure me and I will work". The field is now conditional on the agent being
usable, and a blocked one states its reason in the same place instead.

On the simulator, Hermes selected: where the field was, the container reads
"no pip on the device — reachable by running hermes elsewhere and connecting to
its gateway". Which is the whole truth about Hermes here, and the design for the
client that changes it is in the brief.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "ask hermes agent" pill filled the exchange with an instruction rather than
a conversation. An empty container reading empty is the correct empty state; the
input below it already says what to do.

Also in: a first draft of the gateway client, `HermesGateway` — the socket and
line protocol Hermes's own Telegram front-end uses, since Hermes cannot be
installed on the device at all.

IT IS NOT VERIFIED AND NOT WIRED IN. The harness written against a stub speaking
`tui_gateway`'s protocol compiles and then hangs, so the client is a draft that
proves nothing yet and nothing calls it. It is committed because the design is
right and the next iteration should continue from it rather than retype it; the
harness is parked outside the suite so nothing green claims to cover it. Finding
the hang — most likely the read path or the stub's accept loop — is where the
next iteration starts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The client was never wrong. Against a stub speaking `tui_gateway`'s protocol it
returned both objects correctly on the first try, streamed event then answer,
reassembled from two writes. What hung was the last check in the harness: an
address nobody is listening on.

`NWConnection` does not fail on a refused port. It enters `.waiting(error)` —
"refused, but I will keep trying" — and retries forever, so `connect` sat on a
continuation that `.failed` was never going to resume. For a gateway whose
address the user has just typed, the first refusal IS the answer; retrying in
silence is the hang, not the resilience. `.waiting` now reports unreachable.

    HERMES GATEWAY: the client speaks tui_gateway's line protocol — streamed
    events, split writes, advancing ids, a refused address — MATCH

`verify/hermesgateway` is back in the suite, and `build-one.sh` learned the
source set for it: the client stands alone, a socket and a line protocol, no
engine behind it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The client existed and nothing called it. Now Hermes takes a different path
through `send` entirely: no install, no launch, no terminal, no project — it is
running somewhere else and this is a chat client to it. Claude Code still runs
as a local CLI. Which path an agent takes is a property of the agent.

Hermes is no longer marked blocked, because it was never Hermes that was
blocked — it was having nowhere to send to. With an address it works; without
one the setup field asks for it, which is what that field was always for.

Driven on the simulator against a stub speaking `tui_gateway`'s protocol,
listening on the Mac:

    Hello from the phone            ← typed into the container
    thinking                        ← the streamed event, as a note
    stub gateway here. you said: Hello from the phone

    gateway saw: REQ {'id': 1, 'command': 'Hello from the phone'}

A prompt typed on the phone, a JSON line over a socket, events and an answer
back, rendered as a conversation. The transport is proven end to end; the thing
on the other end is a stub rather than Hermes, and that is the only piece of
this still missing.

Two things strict concurrency forced, both real. The state handler can fire more
than once and a continuation resumed twice is a crash, so the once-only latch is
a locked object rather than a captured `var`. And `[[String: Any]]` cannot cross
an actor boundary, so the client answers with a typed `Event` — id, text, raw —
which is all the caller ever wanted from those dictionaries anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`HermesGateway` is proven — against a stub it streams events, reassembles split
writes, advances ids, refuses a dead address, and put a real conversation in the
container. What it is not is compatible with Hermes, and the brief now says so
before the next iteration builds further on the assumption.

`tui_gateway` does not listen on TCP. It drives the agent over a child process's
stdin and stdout — `proc.stdin.write(json.dumps({"id", "command"}) + "\n")` — and
its network face is the WebSocket layer in `tui_gateway/ws.py` behind uvicorn,
which is what the dashboard attaches to. I read "newline-delimited JSON" and
built a socket for it without checking what carries those lines.

The framing and the `Event` type survive; only the transport under
connect/write/receive has to become a WebSocket, which is the same face Hermes
already offers every front-end that is not a terminal. A stdio-to-TCP bridge
would be fewer changes here and a worse product, because it asks the user to run
a shim instead of talking to what Hermes serves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The official messaging docs settle what two iterations of source-reading got
wrong. `hermes gateway` POLLS platform APIs outbound — Telegram, Discord, Slack,
Signal, Matrix and a long list — and exposes no inbound endpoint. There is no
generic channel: Telegram works because Hermes has Telegram-specific code that
talks to Telegram's servers, so "be a front-end the way Telegram is" was never
an option.

The docs name a separate "Open WebUI + API Server" integration, and that is the
supported way a custom client speaks to Hermes — almost certainly an
OpenAI-shaped chat-completions endpoint, which this app can speak without any
new transport at all.

So `tui_gateway` was the wrong target twice: wrong transport, and an internal
detail rather than an interface anyone is meant to call. I inferred an
architecture from reading source when the project documents its own, which is
how the TCP client and then the WebSocket plan both came to be aimed at nothing.

The brief now says to fetch the API Server page and build nothing until it has
been read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`/docs/user-guide/features/api-server` has all of it. `hermes gateway` starts an
API server on `http://127.0.0.1:8642` speaking plain OpenAI:
`POST /v1/chat/completions` with `{"model", "messages", "stream"}`, answering in
`choices[0].message.content`, with SSE and `hermes.tool.progress` events when
streaming. `Authorization: Bearer <API_SERVER_KEY>` is required for every
deployment including the loopback bind and cannot be turned off. The model name
defaults to the profile name.

And the profiles the user has been pointing at all along are real and specific:
multi-profile routing gives each profile its OWN `API_SERVER_KEY` in its own
`.env`. A saved setup here is a (base URL, key, model) triple, not the single
string the settings currently hold.

So the client is an ordinary HTTP POST. No socket, no line framing, no
WebSocket. `HermesGateway`'s transport goes, and its stub gate with it; the
replacement is smaller than what it removes. Recorded before writing any of it,
because the last two transports were both built on a guess about this exact
question.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`HermesGateway` is gone — socket, line framing, `ResumeLatch`, `Event`, and its
stub gate with it. In its place `AgentAPI` does what the docs describe: POST
`/v1/chat/completions` with `{"model", "messages", "stream": false}` and
`Authorization: Bearer <API_SERVER_KEY>`, reading the answer out of
`choices[0].message.content`. The replacement is a third the size of what it
replaced, which is what happens when you read the interface instead of guessing.

The saved setting is now the KEY rather than the address. Hermes requires bearer
auth on every deployment including the loopback bind and will not let it be
turned off, while `hermes gateway` serves 127.0.0.1:8642 by default — which the
simulator reaches, sharing the Mac's network stack. So the key is the one thing
that cannot be defaulted; the address has a place to live and no field yet.

The endpoint is stateless, so the exchange so far IS the context and the whole
conversation goes up each turn. Notes are ours rather than the conversation's
and are left out — sending them would have the agent answering its own status
lines.

Driven on the simulator against a server speaking the documented shape:

    What port do you serve on
    hermes api server here. you said: What port do you serve on

    server saw: PATH /v1/chat/completions AUTH Bearer test-profile-key
                MSGS [{'role': 'user', 'content': 'What port do you serve on'}]

The path, the bearer token the user typed into the container, and the message
in OpenAI shape. Still a stand-in rather than Hermes itself: the only thing
between this and the real agent is `hermes gateway` running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`verify/hermesgateway` went out with the socket it tested, and `AgentAPI`
shipped without one. This is its replacement, and it is built differently on
purpose: the old gate's stub was written from the same wrong guess as the client
it tested, so the two agreed with each other and neither agreed with Hermes.
This stand-in server is written from the published docs instead.

Nine checks. The request we send — path, bearer header, OpenAI body, whole
conversation newest last — and the four ways it goes wrong: a rejected key
reporting 401, a body that is not the shape reporting that rather than being
handed back as an answer, a closed port reporting why instead of hanging, and a
hostless address refused before a request is made. Plus the address rules: an
empty one is the documented `127.0.0.1:8642`, `host:port` gains a scheme, a
scheme already present is kept.

    AGENT API: the documented request — path, bearer, OpenAI body — plus a
    rejected key, a malformed answer and a closed port — MATCH

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`hermes gateway` binds loopback, which only the simulator can reach by sharing
the Mac's network stack, so a real phone needs somewhere real to point. There
is a field for it now.

It nearly shipped unreachable. Gated behind "the key is not set yet" it showed
during first setup and never again — and a keychain entry SURVIVES deleting the
app, so the usual escape of reinstalling does not clear it either. Found by
switching to Hermes on a freshly installed app and getting no fields at all: the
key was still in the keychain from an earlier run, so the address could never be
asked for. It stands on its own condition now, and submitting one — even the
default — puts it away.

Proven end to end rather than by looking at it: with `127.0.0.1:8655` typed into
the field and stand-in servers on both ports,

    8655 saw: PATH /v1/chat/completions AUTH Bearer test-profile-key
              MSGS [{'role': 'user', 'content': 'Which port did this reach'}]
    8642 saw: nothing new

The typed address, the saved key, the documented path. Claude Code shows no
address field, because it is not reached over a network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
I have said several times that Claude Code was one typed key away. It is not,
and this measures it rather than assuming either way:

    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 returns — identical to its
behaviour with no key at all. Authentication is not the wall. Something in
1.0.128's startup does not complete on this engine, and a real key would have
changed nothing.

Worth knowing before anyone spends a key to find out, and worth correcting since
the last several handoffs said otherwise.

The next experiment is one call: msh runs an installed bin with
`interactive: true`, which hands it to `launchProgram` as a screen-owning
program. `-p` is meant to be the non-interactive mode. Running the same command
through the non-interactive path — `engine.run`, which returns output — separates
"our launch path is wrong for a print-mode invocation" from "the CLI cannot
start here at all".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    echo '' | claude -p 'say hi'     3s
      Invalid API key · Please run /login

Three seconds, from the real CLI, on this engine. It starts, reaches its auth
check, and says so in words. Everything I attributed to the agent — the missing
key, then "something in 1.0.128 does not complete here" — was our own launch
path all along.

Piping 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 `NodeProgram` that owns the terminal. A print-mode
invocation wants to write and exit; given the screen instead, it sits there
forever. `runInstalledBin` passes `interactive: true` for every installed bin.

So the fix is ours and small: dispatch a print-mode invocation
non-interactively. It belongs in how `AgentSession` asks for the command to be
run — a flag through `TerminalSession.run` to `runNode` — not in shipping
`echo '' | …`, which works by accident of the stdin test and would read as
nonsense to whoever finds it next.

Recorded with one loose end: `export KEY=… && echo '' | claude -p …` hung for
63s where the same pipeline without the `export &&` returned in 3. Something
about the compound restores the interactive path, and `AgentSession` exports
before it launches, so that has to be understood before the fix is trusted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`runNode` gives an installed bin the terminal when `interactive` and stdin is
empty and the context has a launcher. The Agent container has no grid — it draws
a conversation — so handing `claude -p` a screen meant a print-mode invocation
that wants to write and exit sat there forever.

`TerminalSession.run` takes `screenless` now, and a screenless run builds its
context with no `launchProgram` at all. That is the honest statement of the
situation: there is no screen to give, so `runNode` takes the path that RETURNS
output. Not a pipe trick — `echo '' | …` produced the same effect by accident of
the stdin test and would have read as nonsense later.

Measured through `TerminalSession`, the container's own path:

    claude -p 'say hi'   [screenless]   3s, finished
      Invalid API key · Please run /login

The real CLI, answering. Against the same command before this change: 42s and
still running, nothing printed.

STILL BROKEN, and recorded rather than glossed: the compound form
`export ANTHROPIC_API_KEY=… && claude -p 'say hi'` hangs for 63s even screenless,
where the bare command returns in 3. Something about `&&` restores the
interactive path. It does not block the container, which runs its export as a
SEPARATE command rather than a compound, but anyone typing that line into the
Terminal container will hit it and it is a real defect in the shell's dispatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last commit blamed `&&` for the 63-second hang. That was wrong, and this is
what the measurement says instead:

    export FOO=bar && echo compound-ok        0s    compound-ok
    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

The last pair is two separate commands on one session, which is exactly how
`AgentSession` runs them, so the compound was never involved. The compound
returns instantly on its own.

What correlates is the key. Without one the CLI stops at its own validation and
prints. With one it gets past validation and makes a real HTTPS call to the API,
and that is where it stops. The suspect is the engine's network path under
whatever HTTP client 1.0.128 uses.

Which means A VALID KEY WILL HANG THE SAME WAY — it takes the same branch. The
screenless fix was real and necessary and is not undone by this; it is simply
not the last thing in the way. I have twice told the user Claude Code was one
key away, and both times the next measurement said otherwise, so the brief now
says not to claim it a third time without evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both HTTP clients reach the real API and come back fast, sending the shape the
CLI would send:

    fetch          0.2s  401  {"type":"authentication_error","message":"API key is invalid."}
    https.request  0.2s  401  same body

So TLS, DNS, the request path and the response path all work under this engine.
Whatever claude-code 1.0.128 does after getting past its own key validation, it
is not a plain call to api.anthropic.com that stalls. A whole area is ruled out
rather than suspected, which is worth a commit on its own.

Three cheaper suspects recorded in the brief, in order: output the screenless
path may be dropping while the program hangs; a SECOND host — onboarding,
telemetry, an OAuth refresh — that a key being present unlocks and that never
answers; or a wait on stdin, since with a key the CLI may be prompting for a
trust or onboarding confirmation and the screenless path hands it a stdin that
never delivers. That last one would be ironic and is the one I would bet on.

Also noted so it is not rediscovered: a `setTimeout` left running in a probe
keeps the engine's loop alive after the work resolves, which reads as a hang and
is not one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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. It is not waiting on a
screen, a prompt, or a stdin that never delivers — which was my bet last commit,
and wrong. The hang is specific to `-p` making its request.

That leaves one suspect that fits every measurement taken: `-p` asks for a
STREAMING response. A plain request/response works both ways in 0.2s. What has
never been tested here is reading a body that arrives in chunks over time. With
no key the CLI fails validation and prints in 3s, never opening a stream; with a
key it opens one, and stops there.

The next test needs no key: serve SSE locally and read it through the engine's
fetch, both `body.getReader()` and async iteration. `verify/sse` and
`verify/webstreams` are nearest and neither covers a chunked body with real
delays between chunks, which is precisely the shape in question.

Four suspects have now been eliminated by measurement — the missing key, the
launch path (real, fixed, not the last), the compound, the network, and now
startup and stdin. Each was plausible and each was wrong until measured, which
is the argument for measuring first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test was "does a chunked body with real delays read correctly", and it does:

    headers at 0.0s status=200
      chunk 1 at 0.0s … chunk 4 at 1.2s
    reader DONE at 1.6s after 4 chunks

Then the run had to be killed. The work finished in 1.6 seconds and the process
did not exit.

That reframes the whole hunt. The question was never "why does claude -p stall"
— it may not stall at all. A program that finishes and never exits is
indistinguishable from one that hangs, and this container only shows output when
a run COMPLETES, so a finished-but-unexited `claude -p` prints nothing forever.
Every measurement fits: startup instant, --version instant, the request itself
0.2s, the stream read 1.6s, and the whole thing "running" at 63 seconds.

So the question is what a consumed streamed body leaves ref'd in the event loop
— a socket, a reader, or a stream that stays a reason to keep running.
`NodeEngine`'s exit is handle-counting, and something here is never released.
`verify/streamlife` and `verify/httpclose` are nearest.

One line to check first: whether the same probe with `await r.text()` exits on
its own. If it does, the leak belongs to the streaming path specifically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Last commit said a streamed body leaves the process unable to exit, and rebuilt
the whole diagnosis around it. That observation was an artefact of my own test:
the watchdog subshell ran `sleep 40; kill -9 …; echo "*** KILLED ***"`, and the
echo fires unconditionally. The process had already exited. I read my own
harness's unconditional message as evidence about the engine.

Measured properly, both forms finish and exit on their own:

    text()        exited after 2s
    getReader()   exited after 1s

So streaming works, exit works, and the `claude -p` hang is still unexplained.
The elimination list is now: the missing key, the launch path (a real bug, fixed,
but not this), the `&&` compound, the network, startup, stdin, streaming, and
event-loop exit. Seven explanations, seven wrong.

The one area never looked at is what the CLI does BETWEEN passing validation and
issuing its request — it writes state: config, onboarding flags, a project trust
record. That fits the one correlation that has held all along: instant without a
key because validation short-circuits first, slow with one because that path is
only reached when the key looks usable. Instrumenting the engine's fs calls
during the hang and seeing what it touches last is the next move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`claude --debug -p 'say hi'`, watched on the LIVE path where a program's output
lands in the transcript as it happens: zero lines in twenty-four seconds.
`--debug` is noisy from the first moment in a healthy run, so this is not slow
work — it is stuck before writing anything at all.

That narrows it usefully. Everything after the CLI begins visible work is out,
and the suspect is an await in its startup that is only reached when a key is
present: the same correlation that has held since the beginning, now with the
extra fact that it produces no output before blocking.

Recorded with the measurement trick it took three iterations to find: the
screenless path returns output only when a run COMPLETES, so it shows nothing at
all about a hang, and `--debug` looked broken when it was simply buffered behind
a run that never ended. Watching requires the non-screenless path and polling
`session.lines`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user set the architecture and it is now the head of the brief: no gateway
on the Mac, no external service — Hermes embeds in the app, and Mouse is the
scoped tool/skill/MCP surface it drives. I ran `hermes gateway` on their Mac
before being told this, which was wrong twice over: it turned a Mouse feature
into a Mac dependency, and it started the user's own services unprompted. That
boundary is in memory now.

The measurements that make this framing the only workable one, from the
on-device CPython 3.14.6 wasi build:

    import ssl      FAILS        import sqlite3   FAILS
    import asyncio  ok           import zipfile   ok
    pip/ensurepip   absent       compiled extensions cannot load at all

No ssl means no Python-side HTTPS ever — so a Hermes that runs here cannot own
its network. It does not need to: the agent loop is pure Python and runs on
wasi; TLS, processes and files are things Mouse already does natively; and
Hermes is ALREADY architected to delegate — shell backends, MCP tools. Mouse
becomes one more backend: the phone itself.

Build order in the brief: a wheel installer (wheels are zips; the zip reader
exists in Runtimes.swift), the stdio MCP bridge that lends Mouse's URLSession,
msh and workspace to the Python process, then a Mouse profile of Hermes with
the first failing import measured on device rather than predicted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stage 1 of embedding Hermes: `pip install` in msh, backed by PyPI's JSON API,
landing pure-Python wheels in a site-packages the wasi CPython imports from.
A wheel is a zip and ZipArchive already reads those; Runtimes.json puts
`{root}/site-packages` on PYTHONPATH.

The contract is deliberately the subset that is TRUE: pure wheels only. This
Python cannot load a compiled extension at all, so a package whose wheels are
all compiled is refused in one line — "pydantic-core 2.x has no pure-Python
wheel — it needs a compiled extension, which this Python cannot load" — instead
of installing something that dies at import. Exact pins (`name==1.2.3`) resolve
through PyPI's per-version endpoint; other operators are refused rather than
misread. Dependencies come from the wheel's own METADATA Requires-Dist, with
markered requirements skipped whole: extras are opt-in by definition, and the
platform this runs on is the one no marker anticipates.

Gated in verify/pipwheel against the REAL registry: a pinned dependency-free
wheel, requests' five-package closure arriving unasked, idempotence in words,
and the pydantic-core refusal naming its reason. And the end-to-end proof
through msh on the engine:

    pip install python-dotenv    installed python-dotenv 1.2.2
    python -c "import dotenv"    dotenv imported from
                                 /usr/lib/python/site-packages/dotenv/__init__.py

Next per the brief: the stdio bridge that lends Mouse's URLSession, msh and
workspace to the Python process — the piece that stands in for the ssl this
build does not have.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox and others added 19 commits August 14, 2026 00:15
Read before designing this time. Two facts from NodeEngine's WASI: `wasi.start`
is synchronous — the JS thread is blocked for the whole run, so no host answer
can reach the module mid-execution — and `fd_read` on stdin returns 0 bytes,
instant EOF. A resident MCP-over-stdio server inside the wasm process is
therefore impossible on today's engine, and building toward it would have been
the fourth transport aimed at nothing.

What the constraints permit is one Python invocation per agent step: Swift
writes the turn state to a file, Python runs one loop step and exits having
written either an answer or a tool request, Swift executes the tool natively —
model calls on URLSession with real TLS, shell on msh, files on the workspace —
and reruns Python with the result. State lives in files between steps, which is
how Hermes persists sessions anyway. Cold start per step (~1–3s measured) is the
price, and a resident process becomes an optimization for when the engine grows
blocking stdin, not a prerequisite for Hermes answering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bridge runs. Typed into the container on the simulator, with only a
stand-in model on the other end:

    Hello hermes, are you running on the phone?
    pkg install python              ← the runtime auto-installed, 14 MB, as a note
    llm.complete                    ← the step's tool call, as a note
    hermes api server here. you said: Hello hermes, are you running on the phone?

And what the model endpoint received proves WHERE the loop ran — the system
prompt is composed by the step driver executing on the device's own wasi
CPython, not by Swift:

    MSGS [{'role': 'system', 'content': 'You are Hermes Agent, running embedded
           in Mouse on an iPhone. Mouse executes your tools. Answer concisely.'},
          {'role': 'user', 'content': 'Hello hermes, are you running on the phone?'}]

The shape is the brief's: Swift writes turn.json, Python decides and exits,
Swift executes the tool it asked for — llm.complete on URLSession's TLS, shell
on msh, read_file on the workspace — and reruns Python with the result. Six
steps bounded. Hermes's settings became (model endpoint, LLM key): the embedded
loop needs a model, not a gateway, and the key is typed once on the phone and
kept in the keychain.

Honest edges: the driver is a 12-line stand-in for Hermes's own loop — stage 3
replaces it via pip with hermes code and measures the first import that fails;
the model on the other end here is a stand-in, so the words are an echo, but
every hop they crossed is the real one; and a saved endpoint address currently
has no way to be edited once set, which needs a settings affordance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`pip install hermes-agent==0.19.0` through our own pip, against the real PyPI:
eight packages land clean — hermes-agent's 9.9 MB pure wheel itself, openai
3.0.0, httpx, rich, tenacity, fire, python-dotenv, certifi — and the closure
stops at pyyaml, which publishes no pure wheel. The refusal is our own honest
one.

Then on the device, with those eight installed:

    import openai     ModuleNotFoundError: No module named 'zlib'
    import run_agent  utils.py line 14: import yaml

The first is not a packaging wall at all: the wasi CPython BUILD ships without
zlib. That is an engine/runtime-artifact gap — a build of CPython with zlib in,
or a shim — and it blocks the whole openai SDK before hermes's own code is even
reached. The second is the pyyaml wall landing exactly where predicted, in
hermes's utils.py.

Real hermes code is ON THE DEVICE now — 9.9 MB of it, importable up to the
exact lines named above. The distance to `import run_agent` succeeding is two
named problems, not a mystery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two walls stood between the device and hermes's code importing. One falls here.

THE ARTIFACT. brettcannon's CPython wasi build ships no zlib, and no zlib kills
`import openai` before any agent code runs. VMware Labs' 3.12.0 wasi build
compiles zlib in — proven on our own engine before the swap:

    zlib+gzip OK on 3.12.0
    compress roundtrip: mouse

Runtimes.json now points there: 11.5 MB tar.gz, hash pinned, PYTHONHOME at its
usr/local prefix. Its standard library ships as python312.zip through zipimport
— which is exactly WHY that build carries zlib — so the pkgpython gate now
checks for the zip rather than a loose encodings/ that never existed. 3.14.6 to
3.12.0 is a real downgrade, taken knowingly: hermes wants >=3.10, and a stdlib
that cannot inflate is worth more than two minor versions.

THE CLOSURE. One compiled dep used to abandon everything queued behind it —
openai lost typing_extensions and friends to hermes's pyyaml. A package with no
pure wheel is now fatal only when asked for BY NAME; a transitive one is skipped
and named:

    skipped 7: pyyaml, cryptography, psutil, pillow, jiter, markupsafe,
    pydantic-core — imports needing them will say so

Whether a skipped dep matters is measured at import time, which is an answer;
refusing the whole closure was a guess.

WHERE THE IMPORT STANDS NOW, on the device: `import openai` gets through zlib
and typing_extensions and stops at pydantic_core — compiled Rust, unloadable on
wasi, ever. That is the SDK's wall, not the loop's: in this architecture the
model call is Mouse's tool on URLSession, so the openai SDK never needs to
import on the device at all. What hermes's own modules need is the next
measurement.

Gates: pkgpython MATCH on the new artifact end to end through msh; pipwheel
MATCH after the closure change (a requested compiled package still refuses).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pip gains the house substitution pattern, Python edition: pyyaml has no pure
wheel and never will, so asking for it installs ruamel.yaml — a PyYAML fork,
pure, already in hermes's own pins — plus a `yaml.py` adapter exposing the
PyYAML surface callers actually use, dumper subclasses included. Substitutes
run BEFORE the already-installed check so a grown adapter reaches installs that
exist; the first version did not, and the fix was invisible until it did.

`Path.home()` was next — the wasi env had no HOME. Runtimes.json sets HOME=/,
which puts `~/.hermes` in the workspace: per-project agent state, which is the
right shape for this app anyway.

Measured on the engine after both:

    import yaml; yaml.safe_load('a: [1, 2]')   →  {'a': [1, 2]}
    import run_agent                            →  319 seconds, zero output,
                                                   still running

hermes's utils.py now imports past every line that stopped it before, and the
import chain hangs somewhere new WITHOUT an error. The brief carries the next
measurement: -X importtime on the live path, where the last import started
before the silence names the module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    RUN_AGENT OK (warm)

Hermes's own code — the 9.9 MB wheel, agent/, hermes_cli/, gateway/, tools/ —
imports to completion on the phone's Python. The ladder it climbed, each rung
measured before it was built:

- The "hang" was a crawl. -X importtime on the live path showed imports
  streaming at hundreds of ms each; nothing was stuck, everything was slow.
  The pyc cache is on ({root}/pycache, PYTHONDONTWRITEBYTECODE removed):
  cold ≈ 6 minutes, warm ≈ 4. Startup is now the biggest problem left, and it
  is a performance problem, not a correctness one.
- `import ssl` (hermes_cli/auth.py:26): a pure shim laid by pip whenever it
  touches site-packages — imports clean, constants and context classes there,
  any actual TLS use refuses in words. asyncio's sslproto is satisfied.
  Python-side TLS does not exist here BY DESIGN; network belongs to Mouse.
- `import webbrowser`: absent from this build's stdlib zip. Shimmed: there is
  no browser on that side of the bridge, and open() saying False is true.
- `concurrent.futures.thread`: the build omits it because wasi has no threads.
  sitecustomize.py installs an INLINE ThreadPoolExecutor — same Future surface,
  the work done on the only thread there is.

Left standing, and named: a hermes plugin fails on hashlib.scrypt (this build's
hashlib lacks scrypt), non-fatal; and the four-minute warm import.

Next: the bridge stops using its 12-line stand-in and drives hermes's own loop,
one Python invocation per step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eplay

run_agent.py:5295 — AIAgent(base_url, api_key, model).chat(message) -> str,
synchronous. Its transport is the openai SDK, which cannot import on the device
(pydantic-core), and wasi Python has no sockets regardless. So the step driver
installs a fake openai module before importing hermes: recorded responses
replay in order, the first unrecorded call raises a Capture the driver writes
as the llm.complete tool request, and Mouse's URLSession does the network.

Each step replays prior turns deterministically with no network. The cost that
matters after this is STARTUP — four minutes warm — which points at one
resident invocation per conversation once the engine grows blocking stdin, or
at import pruning. Recorded in the brief; implementation is its own iteration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    agent.turn_context     conversation turn: session=20260814_082621 …
                           msg='Say exactly: hermes loop lives on the phone'
    agent.conversation_loop Turn ended: reason=text_response(finish_reason=stop)
                           model=hermes-agent api_calls=1/90 response_len=77

    answer: "hermes api server here. you said: Say exactly: hermes loop lives
             on the phone"

That is hermes-agent's real conversation loop — session, turn context, budget
accounting, its own log lines — running on the phone's Python, with Mouse as its
transport. Step one: the loop builds its request (messages, model, tools,
timeout), the driver captures it, Swift sends it through URLSession with the
key saved on the phone. Step two: the recorded reply replays into the loop,
which accepts it, ends the turn with finish_reason=stop, and returns the answer
through AIAgent.chat.

Finding the seam took five wrong layers, each eliminated by instrumentation the
step now carries: the fake openai module (never called — hermes streams over
raw httpx), the universal proxy (looked permanently closed to
_is_openai_client_closed, because a proxy is truthy), the module-level
interruptible_api_call (the loop calls the agent's METHODS), spies on those
methods (which showed kwargs in and silent None out). The replacement is two
method assignments: `_interruptible_streaming_api_call` and
`_interruptible_api_call` ARE the transport boundary, they receive OpenAI-shaped
kwargs and must return an assembled OpenAI-shaped response, and everything
beneath them — worker threads, httpx streaming, retry ladders — is machinery for
a network this Python does not have.

The driver also ships hermes's own log tail in out.json (the QueueListener
patch orphans its file logs), which is what made every diagnosis above readable
instead of guessed.

Not yet the stop condition: this is the headless path, probe-driven. The
container UI pass with a screenshot is what remains, plus the known cost — two
steps at roughly five minutes each of interpreter startup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Typed into the Agent container's chat, driven entirely through the UI:

    hermes agent on local/agent-test
    [Hello from the container]
    pkg install python
    pip install hermes-agent==0.19.0
    llm.complete
    hermes api server here. you said: Hello from the container

Every line above is the container telling the truth about what it did: it
installed CPython on first use, installed the real hermes-agent 0.19.0 wheel
and its pure closure from PyPI through our own pip, ran hermes's conversation
loop on the device's Python, captured the loop's model call, made it on
URLSession with the key saved in the keychain, replayed the reply into the
loop, and rendered what the loop returned — hermes's log confirming
finish_reason=stop turn completion in the headless twin of this run.

The far end here is an OpenAI-shaped stand-in, so the words are an echo; the
user's saved endpoint and key point the same machinery at any real model. The
embedded path now installs the agent itself when missing (it only ensured
python before), and the install line is pinned to the measured 0.19.0.

This is the Hermes half of the loop's stop condition, screenshot taken. The
Claude Code half remains open on the engine hang that a present API key
triggers — unchanged, measurements in the brief.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The claude-code hang, measured from every side this session could reach:

    dead base_url                hangs identically — not the network target
    refused net.connect          error event in 1ms — sockets fine
    execSync('security …')       instant throw — no bridge deadlock
    the grid during the hang     empty — no hidden TUI waiting for a key
    onboarding config seeded     read from the right place, still hangs

Plus the earlier round: fetch, https.request, streaming, event-loop exit,
startup, stdin. With a key present the CLI awaits something that never
resolves, before its first write, regardless of endpoint, with every measurable
external ruled out.

The honest conclusion is that guess-and-measure has hit its limit against a
9.4 MB minified bundle, and the next move is an engine diagnostic that answers
"what is this process waiting ON" — pending timers, sockets, host calls —
dumped on interrupt. The streaming investigation needed the same question
answered and also lacked the tool. That is where this thread resumes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The engine's liveness accounting always knew why a run could not exit; now an
interrupt SAYS it. `outstanding` became a labeled ledger — hold("http request"),
release("dns") — and ^C reports what was pending, with http streams carrying
their destination host:

    interrupted while waiting on: 9 timers, 1× http stream to statsig.anthropic.com

Second use of the tool, and the mystery that survived nine hand-built
eliminations has a name: with a key present, claude-code 1.0.128 initialises
statsig before its first output, and that streaming request never completes on
this engine. Held past 127 seconds against URLRequest's default 60-second
timeout — so the session's delegate events are not being delivered at all,
which is now a small, reproducible engine bug instead of a 9.4 MB mystery.
The telemetry kill-switches (DISABLE_TELEMETRY,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC) do not bypass it in 1.0.128.

The ledger cost one self-inflicted crash on the way: the site converter
rewrote the increments inside hold()/release() themselves, and infinite
recursion reads as SIGBUS. Worth remembering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    statsig.anthropic.com                        NXDOMAIN
    claude 1.0.128 + key on our engine           silent forever
    claude 1.0.128 + key on REAL node v22        killed at 40s, zero output

The pinned claude-code awaits statsig initialisation before its first print;
the statsig host has since been retired from DNS entirely; its client retries
forever. Every symptom this session chased — key-dependent, pre-output,
endpoint-independent — is the behaviour of the real thing on real node. The
engine's only crime was fidelity.

The bare-fetch repro also cleared the engine's failure path (fast, clean,
released), and exposed that two earlier "hangs" were my own watchdogs echoing
KILLED unconditionally — the same instrument error twice. The interrupt
ledger, which cannot lie about what is held, is what broke the case.

Next: pick the newest JS-only claude-code whose statsig init fails open, and
measure it the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The JS line of claude-code runs far past the pinned 1.0.128 — cli.js ships
through at least 2.1.98, and the installer-stub era starts by 2.1.232. On real
node, 2.1.98 with a key answers in two seconds. So the modern JS version fixed
the statsig hang that killed 1.0.128 everywhere, and the remaining problem is
ours alone.

Its shape is sharper than anything measured before: interrupting the engine
run produces only the ^C echo — not the pending-work report, and not the new
busy report either. The loop never got back to its own cancelled check. One
synchronous JS job runs forever; nothing is pending; the thread is spinning
inside a single job.

The interrupt ledger gained the busy report on the way: an interrupt with
nothing pending now says "the program was computing or spinning, not waiting"
— separating stuck-on-IO from stuck-in-a-loop. This hang is the rarer third
kind: stuck inside one job, where only a JSC execution-time-limit watchdog can
reach, and that watchdog — which terminates a runaway job and names its JS
stack — is the designed next tool in the brief.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `sample` of the hung process ended the mystery the ledger and watchdog had
cornered: 2331 of 2334 ticks inside ICU's RegexMatcher::find, under
transpileESM's live-binding shadow scan. The scan runs whole-source regexes per
imported name, patterns like `\([^()]*name[^()]*\)` are superlinear, and the
chunk claude-code 2.1.98 loads on its authenticated path is megabytes of
minified single line. Not statsig (that was 1.0.128's own grave, dug
everywhere), not the network, not a JS spin — our transpiler, quadratic.

The guard: live-binding promotion now applies only under 4 MB of source. A
bundle that size is a build artifact, not a hand-written module whose
`export let` needs live reads; the snapshot path is what this engine did for
its whole life before live bindings and is correct for everything a bundler
emits. Vite's biggest real chunk (2.1 MB) stays promoted.

With the hang gone, 2.1.98 ran and named three real gaps, each now fixed:

- `node:stream/consumers` did not exist: added, with `stream/web` beside it,
  both in coreModules so the `node:` form resolves.
- `Symbol.dispose`/`Symbol.asyncDispose` are absent from this JSC, and bundles
  compiled for explicit resource management throw "Object not disposable" from
  their own helpers. Polyfilled first thing in the bootstrap — the engine's own
  timers already attach [Symbol.dispose].
- **fs.rename could not replace an existing file.** FileManager.moveItem
  refuses when the destination exists, we swallowed the refusal, and every
  tmp-then-rename atomic write silently did nothing — claude's stranded
  .claude.json.tmp.* files were the fingerprint. It is rename(2) now, which
  replaces atomically, measured before (a=one) and after (a=two).

Also in: NodeEngine.contextConfigurator / globalContextConfigurator, the
neutral hook Mac-side probes use to arm dlsym'd diagnostics (the JSC execution
watchdog) without any private API shipping in the app.

Still open: 2.1.98's `-p` exits rc=0 with no output at all — --version prints,
the run completes, nothing is written. The next split is whether its API
request goes out (stand-in base_url) or it exits before sending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The five fixes landed it at a stranger place: --version prints, -p runs to
completion and exits 0 having printed nothing and SENT nothing — a stand-in
base_url logged no request — and its own session record classifies the -p run
as "kind":"interactive". The flag arrives (argv measured intact end to end),
so print mode is being refused after parsing, silently. --print hangs instead,
which is its own unexplained divergence.

The brief carries the three next moves: 2.x's --debug on the live path, the
telemetry failed-events files it writes (which name event types), and the
ledger on the --print hang.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude 2.1.98's telemetry named tengu_unhandled_rejection on every mute run; a
first-registered rejection listener caught the object the CLI's own handler
sanitised away:

    REJECTION: child_process.spawnSync's `timeout` option is not available…

Our guard, thrown at claude's ripgrep probe, swallowed by its handler, exited
as rc=0 with nothing printed. The guard meant well — an ignored timeout never
fires — but a synchronous msh run has COMPLETED by the time a timeout could
matter, so ignoring one states the truth and refusing it killed the program.
spawnSync now refuses only `input`, which really cannot be honoured without a
live child.

The CLI still produces no output after this fix — one more layer remains — but
it no longer dies at startup, and the brief carries both working diagnostic
recipes: the telemetry JSONL (event_data.event_name is the signal) and
in-place cli.js instrumentation under a MOUSE_HOOK marker, which survives
main-detection where a require() harness does not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pin moves off 1.0.128, which died upstream: it awaits statsig
initialisation and statsig.anthropic.com no longer resolves, on real node
and on this engine alike. 2.1.98 is the newest release that is JavaScript
the whole way down, and with the spawnSync timeout guard already relaxed it
runs -p to completion here — its answer rendered in the container's
exchange on the simulator, streaming SSE and all.

The address field now serves Claude too: `endpointVariable` in the catalog
names the env var (ANTHROPIC_BASE_URL) that an address exports before
launch, so any Anthropic-shaped endpoint — a relay, a proxy, a test double —
is a setting, not a code change. Empty means the real API.

Two field bugs surfaced by driving the UI: setup and address committed only
on return, which a phone keyboard rarely sends — they now commit on blur
too; and a saved value never hid its field, because keychain and
UserDefaults are invisible to @observable — reads now subscribe the calling
view through the `version` counter the writes already bumped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The container stops pretending a key field is the whole auth story. Picking
Claude Code without credentials now offers both of its real ways in: the
ANTHROPIC_API_KEY field, and a sign-in row that runs `claude setup-token` —
claude's own OAuth flow, ink screen and all — on a terminal grid embedded
where the exchange sits. The grid is the Terminal container's renderer,
opened to internal; the chat input feeds the program while it runs, so the
pasted code travels the same field every message does.

Two chips ride under the screen: one reassembles the OAuth URL from its
hard-wrapped rows into a real link (Safari lands on Anthropic's login), and
`stop` takes the terminal back in a single tap — setup-token swallows ^C as
a keystroke, measured, so the chat offers the close button rather than the
terminal's two-press ritual.

A finished sign-in leaves `.claude/.credentials.json` in the workspace home,
and `authenticated` — key saved, or that file present — is now the send gate
and the reason both auth rows disappear.

Measured on the simulator: the flow renders, the URL opens, a bogus code
comes back "OAuth error: Invalid code", Enter retries with a fresh URL, and
stop returns to the chat. The remaining step belongs to the account owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign-in was per-project because the engine's homedir WAS the project —
homedir() returned '/', and '/' is the workspace root. Now the shell mounts
a shared home at /home (RuntimeStore.home, beside the /usr runtimes mount,
unconditional for the same reason: a filesystem whose shape never changes),
os.homedir() honors $HOME the way real node does, and the Agent container
exports HOME=/home before anything runs. Claude's credential and config land
there once and every project finds them; cwd never moves, so the agent still
works on the ring's workspace.

Measured on the simulator: a fresh sign-in wrote .claude/.claude.json only
to the shared home, and the workspace's old copies kept yesterday's
timestamps. The Terminal container is untouched — its HOME stays '/', and
nothing outside the Agent container exports otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant