Agent container: Claude Code and Hermes behind the chat - #21
Open
ThyFriendlyFox wants to merge 49 commits into
Open
Agent container: Claude Code and Hermes behind the chat#21ThyFriendlyFox wants to merge 49 commits into
ThyFriendlyFox wants to merge 49 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
-pper turn, streaming from the API. Verified answering in the container on the simulator.Auth, the way the agents document it
sign inrow hostsclaude 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, andstopreclaims the terminal in one tap. The API-key field remains the other way in./home,os.homedir()honors$HOMElike real node, and the Agent container exportsHOME=/home— sign in once, every project has it, while cwd stays the project. Measured: sign-in writes only to the shared home.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/)stream/consumers+stream/webcore modules,Symbol.dispose/asyncDisposepolyfill before timers attach, POSIXrename(2)for atomic replace, and the spawnSync guard relaxed to refuse onlyinput(its refusal oftimeoutwas the silent startup death).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).versioncounter — a saved key hides its field immediately.For a reviewer
AgentSessionis the seam: embedded agents step through a file bridge (.hermes-bridge/turn.json↔out.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)..claude/agent-brief.mdcarry the evidence trail per phase.🤖 Generated with Claude Code