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