Skip to content

feat(herdr-core): show the core's other machines as PCs of their own - #2955

Merged
devswha merged 8 commits into
mainfrom
feat/hc-machines
Nov 5, 2026
Merged

devswha merged 8 commits into
mainfrom
feat/hc-machines

Conversation

@devswha

@devswha devswha commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Fixes #2497

What

With OVIS_RUNTIME=herdr-core, every other machine the core reaches (hc machine add vps, or added in the Herdr app) is now a PC of its own in the sidebar, reached through this PC's core. Nothing of Ovis is installed on that machine. Before, those machines did not show at all (since #2795 their terminals were only kept out of this PC).

A PC of the core shows:

  • its workspaces and tabs, as its own herdr-app document lays them out (doc.get { on }), or as Ovis's labels and ovis-layout document there;
  • its live terminals, typing, screen reads, status and alerts;
  • new workspaces, tabs and splits, started there (terminal.run { on }).

How

The server already shows a remote PC through an endpoint { url, token } that serves herdr's API (an SSH bridge, or the relay forward since #2829). A machine of the core gets the same thing, run locally:

  • server/herdr-core/machine-scope.ts: MachineScopedConnection, an HcConnection that shows the core as that machine sees itself. Only lab/… terminals are listed, without the prefix (parent too). Every address goes back with the prefix. terminal.run, doc.get and doc.set get on: lab. The event stream keeps only that machine's events, including its doc events (machine: "lab"). The adapter, the attach and the herdr.seen marker therefore run unchanged.
  • server/herdr-core/machine-entry.ts: a server per machine (createServer({ bridgeOnly: true })) on loopback with a random token, over that connection. OVIS_HC_MACHINE selects the scoped connection in runtime.ts.
  • server/herdr-core/machine-bridge.ts: listHcMachines (machine.list, temporary machines left out) and startMachineBridge, which starts that server as a child. The child runs with an empty home folder of its own and only the program and core-finding variables. This PC's agent transcripts and processes are not that machine's, so none can be taken for one of its panes. For the same reason, pane.process_info gives no pids for another machine, and worktree.* is refused there, because git would run here. The child ends when this server's stdin pipe to it closes.
  • server/machines.ts: a new PC kind, hc, read from machine.list every 5 s. Its id is hc-<machine id>, which is stable across renames. A rename restarts its server under the new name, because addresses change. A machine that is not connected shows hc's reason. hc PCs are never saved to machines.json, and rename, remove and setup are refused ("add, rename or remove it with hc machine or in the Herdr app"). In the sidebar, the PC's manage panel says the same instead of offering those buttons.

Answers to the issue's open questions:

  1. SSH and relay PCs stay listed beside the core's machines.
  2. The PC id is the hc machine's permanent id.

Safety (from the review round, each reproduced first with three isolated cores, one in its own mount namespace):

  • hc names a machine by a name another machine can take (hc machine rename). Every scoped call goes to hc under the machine's permanent id (<id>/t3, on: <id>), which hc takes wherever it takes the name, so no call can reach another machine. The current name, looked up by id first, is used only to filter what hc lists and reports by name. Before this, input to the old lab landed on vps after lab→lab2, vps→lab; after it, the call is refused and nothing reaches vps.
  • The machine's server (localFiles: false) refuses the routes that read or write this disk for its panes: /api/fs/*, pane/files, pane/image, workspace/diff/*, workspace/directories, workspace/pr* (gh and git run there). Before this, it served a file only this PC has and could not see a file only that machine has. A new workspace's folder goes to that machine unchecked: a folder that exists only there now works.
  • Its adapter socket sits in a short temp folder, so a long state folder cannot push it past the unix socket limit. A scoped event line past 4 MiB is passed whole to the adapter's reader, which refuses it.

Known limits (in docs/guide.md):

  • Chat history of agents on that machine is not read: their transcripts are there.
  • That machine's files are not reachable yet (refused, not guessed). The client still offers those entry points and shows the refusal.
  • Pins and "seen" are not shared with the Herdr app on that machine.
  • Each machine costs one more Bun process on this PC.

Validation

  • bun run check fast: pass on the merged head (11959 pass, 0 fail; typecheck, build, generated types fresh).
  • bun test server/herdr-core/ server/hc-machines.test.ts: pass. The new scoped-adapter tests and the manager tests go red when their owner is broken: on dropped, another machine's terminals kept, pids kept, hc PCs saved, no restart on rename, no id check, disk routes served.
  • OVIS_TEST_HC_BIN=<hc 0.1.14> bun test ./server/herdr-core/live.test.ts and the same with hc 0.1.17: pass. The new live test starts two isolated cores, adds the second with hc machine add --via, then starts, types into and reads a terminal there through the scoped adapter.
  • Manual: a real server on an isolated core holding machine lab (another isolated core). lab appears as a connected PC with its own workspaces. Its live terminal attaches with history, and typing in the browser reached lab's shell (hc read there showed it). Workspace create, input and read through /api/machines/hc-…/ all work. The local PC shows only its own terminal. Stopping the server ended the per-machine child.
  • Screenshots of the manage panel, dark and light at 1280 and 390, are in the comment below.

Each machine in hc's machine.list (not temporary) is a PC, reached through this PC's
core: a server per machine on loopback over a connection scoped to that machine's
terminals and documents. Nothing of Ovis is installed there.

Fixes #2497
@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Explain Change →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Screenshots: a server on an isolated herdr-core holding machine lab (another isolated core). lab is a PC of its own with its own workspaces; its manage panel says it is herdr-core's, with no rename, disconnect or remove.

1280 390
Dark
Light

Visual check: PASS on all four. The hint wraps inside the panel at 390, the green connected dot shows in both themes, the PC header row keeps its manage/fold/new buttons, and no empty button row is left under the hint.

- Each call first checks hc still names the machine by its id: after a rename,
  the old name no longer reaches whichever machine took it.
- The machine's server refuses the routes that read or write this disk for its
  panes (files, diffs, pasted images, folder picker), and passes a new
  workspace's folder to that machine unchecked.
- Its adapter socket goes in a short folder of its own, and an event line past
  the adapter's limit is no longer held whole.
hc takes a machine's permanent id wherever it takes its name (`<id>/t3`, `on`):
a call made under the id cannot reach another machine that took the name between
the name check and the call. The machine's server also refuses the pull request
routes, which run gh and git on this disk.
@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Review rounds (omo + Codex Terra xhigh)

Round 1 (head acc527a): two HIGHs and one MEDIUM from Codex, the same file problem from omo. Each was reproduced with three isolated hc cores, one of them in its own mount namespace, then fixed with a test that fails without the fix:

  • Rename misroute (HIGH): input to the old lab landed on vps after lab→lab2, vps→lab. Calls now go to hc under the machine's permanent id (<id>/t3, on: <id>), so a call cannot reach another machine. Re-run: refused, nothing reached vps.
  • This PC's disk used for that machine's files (HIGH): the machine's server refuses the fs, pane files and image, diff, folder picker and PR routes (localFiles: false). Re-run: 501 unsupported.
  • A folder that exists only on that machine was rejected (MEDIUM): the folder is now passed to hc unchecked. Re-run: the workspace opened in /mnt/onlylab on lab.
  • The adapter socket now lives in a short temp folder, and an over-long event line goes to the adapter's reader, which refuses it.

Final review at da4c017: Codex found no HIGH. omo found no HIGH and blocks on MEDIUMs, which go here as agreed after one fix round:

  • MEDIUM, reproduced at unit level: shell-started agents (omo, gjc) are offered from this PC's PATH and never confirm as started on an hc PC. The agent catalog and Install also describe this PC. Follow-up: herdr-core: agent facts for another hc machine's PC come from this PC #2967.
  • MEDIUM, mechanism only: an open event stream filters by machine name until the 5 s poll restarts that PC's server, so in a double rename within 5 s, status events of the machine that took the name could show for up to 5 s. Typing and every other call stay on the right machine (by id).
  • LOW: a killed machine server leaves its temp socket folder behind.
  • LOW: every call to that machine adds one machine.list round trip (the name check).
  • LOW: pins and "seen" are not shared with the Herdr app on that machine (documented in docs/guide.md).

@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Local merge gate (owner-approved): bun run check fast passed at this head (11989 pass), threads resolved, merges cleanly. Codex Terra xhigh: no HIGH. The fix round closed two reproduced HIGHs (rename misroute -> calls by machine id; this PC's disk served as another machine's -> file/git routes refused), verified live with three isolated hc cores. omo's remaining findings are MEDIUM only and do not block (standing rule); listed above, the reproduced one is #2967. Since that review only origin/main was merged in. Merged without waiting for GitHub CI.

@devswha
devswha merged commit d2a2c6a into main Nov 5, 2026
8 checks passed
@devswha
devswha deleted the feat/hc-machines branch November 5, 2026 11:27
devswha added a commit that referenced this pull request Nov 5, 2026
#3044)

## Change

Closes #3043, the follow-up #2814 left open ("Starting an agent from a
pane stays a follow-up"). The issue is new: this PR is here so the code
can be read alongside it, and it can wait for a maintainer's yes there.

With `OVIS_RUNTIME=herdr-core` on Windows, **New workspace** and **New
tab with agent** now start the agent in the pane's shell. Until now
`server.agent_manifests` offered no kind there, because `agent.start`
took only a POSIX shell: every pane Ovis opens on Windows runs hc's own
PowerShell, a quoted name alone is a string in PowerShell, Ctrl-U types
into its line, and hc names only the shell in front of a pane, whatever
runs in it.

On win32 only (`adapter.ts`, `shell-agent.ts`):

- **Which panes.** hc's own PowerShell (`-NoExit -Command` with its
prompt hook), a PowerShell with interactive switches only, and cmd
without `/c`, `/k`, `/r` or `/v` (delayed expansion reads `!`, which
nothing here quotes for). The app's program terminal (`powershell.exe
-NoExit -Command "& 'claude'"`) stays `agent_pane_unsupported`, and so
does a POSIX shell: Git Bash's MSYS rewrites an argument that looks like
a path (`/review the diff` arrives as `C:/Program Files/Git/review the
diff`), the variable that stops it would stay set for every command the
agent runs, and what runs in WSL is in no Windows process table.
- **The line.** PowerShell gets `& 'claude' ...`, cmd `claude "..."`,
neither with Ctrl-U. cmd's name goes unquoted: a `.cmd` shim found on
the PATH under a quoted name reads the current folder as its own
(`%~dp0`). npm's own shim guards against this; one that reads `%~dp0`
directly failed with `Cannot find module` in the real-shell check below.
The name is only letters, digits and `-`.
- **Arguments.** `powershellNativeArgs` encodes them for Windows
PowerShell 5.1 and throws for what 5.1 would split (`"fix the bug"`,
`x="a b"`) or PowerShell 7 might change. `cmdQuote` refuses `"` and `%`.
A refusal answers `invalid_params` before anything is typed.
- **Busy and started.** These come from the shell's descendants in a
fresh process table (`windows-processes.ts`), console hosts aside. A
table that cannot be read is busy before typing and says nothing while
waiting. `AdapterOptions` gains `platform` and `processTable` (defaults
`process.platform` and `windowsProcessTable`), so all of this is tested
on any host. The input turn around the check and the typing stays.
- **Another machine of the core (#2955, #2986).** Its terminals are not
in this PC's table, so a Windows PC keeps answering no manifests for it,
and `agent.start` refuses with `agent_pane_unsupported` before typing
and without reading the table. A Linux or macOS PC starts there as
before, by what hc reports.
- **Command Code stays off the list.** Its kind and command are both
`cmd`, which on Windows is cmd.exe: it would read as installed, and
picking it would open a nested cmd.exe, so it is not offered and
`agent.start` refuses it. `session-history.ts` resumes it as `cmd
--session <id>`, which is left alone here.

`server.agent_manifests` offers the other kinds on win32 again.
`agents-routes.ts` keeps its logic; a comment and a test title that said
Windows offers none are corrected.

**Linux and macOS are unchanged.** `plainShell` is untouched and is what
`platform !== "win32"` runs. The typed line with its Ctrl-U, the busy
and started checks and their error codes are the same; see the
differential run and the macOS runs below.

Docs: `docs/guide.md` no longer says this runtime is refused on Windows,
which #2814 made false, and the "Starting an agent" bullet describes the
Windows shells, the POSIX exclusion, the busy rule and the refused
arguments.

Known limits:

- An agent PowerShell starts through a `.cmd` file alone has its
arguments read by cmd as well, and the quoting cannot know that:
`%NAME%` and `^` change, and an `&` that cmd reads outside quotes, where
a `"` in the argument can put it, ends the agent's line and runs the
rest (`fix "a&echo ran&b" now` ran `echo ran`). The caller can type into
that pane anyway, so this is no escalation, and the only free-text
argument the UI sends is Codex's first prompt. An npm install also has a
`.ps1`, which PowerShell prefers and which passed everything intact.
Where the execution policy blocks scripts (`Restricted`, the Windows
client default), PowerShell refuses that `.ps1` and does not fall back
to the `.cmd`: the start fails, as typing it by hand does, and the pane
says why.
- cmd looks in the pane's folder before the `PATH`, as it does for what
a person types: a `claude.cmd` in a repository runs in place of the
agent. hc's default shell is PowerShell, so a cmd pane is rare.
- A command PowerShell runs in itself (`Read-Host`) has no program below
the shell for the table to show: the pane reads as idle, and the command
takes the line as its input. Text left at a Windows prompt runs with the
line, as no one key clears it in every editing mode. A new tab's shell
has neither, so this takes a start into a pane someone is using.
- The 10 s wait for the agent starts when the line is typed. A shell
whose prompt takes longer than that (a slow profile) can answer
`agent_start_failed` and run the agent afterwards, as a slow POSIX shell
can.
- The started check reads the first two words of a program's command
line, as on POSIX, so an agent run as `node --flag script.js` can answer
`agent_start_failed` while it runs.

## Validation

Red first: with the tests and without the fix, 6 new Windows adapter
tests failed and `shell-agent.test.ts` failed to load
(`powershellNativeArgs` not exported). The POSIX pins passed on the
unchanged code.

End to end with a real hc: a real core in a scratch session, the real
`server/index.ts` and a browser, with a stand-in agent binary
(`.github/platform-check/e2e.ts` on my fork, not part of this PR). It
opens a workspace, types through the API and through the page's
terminal, starts the agent twice, reads back the arguments it received,
closes everything and checks nothing is left:

| Host | hc | Shell | Result |
| --- | --- | --- | --- |
| Windows 11 (real PC) | 0.1.18 | Windows PowerShell 5.1 | 23 ok, 0
failed; `say "hi"`, an empty argument, `C:\dir with space\`, `it's`,
`$env:PATH` and `한글 인자` arrive unchanged; `"fix the bug"` is refused
with nothing typed |
| GitHub `windows-2025` | 0.1.17 | PowerShell 7 | 23 ok, 0 failed; `a
b`, `it's`, `$env:PATH` and `한글 인자` arrive unchanged; `"fix the bug"` is
refused with nothing typed |
| GitHub `macos-15-intel` | 0.1.17 | zsh/bash | 19 ok, 0 failed |
| GitHub `macos-14` and `macos-26` (arm64) | 0.1.17 | zsh/bash | 19 ok,
0 failed on each |
| GitHub `ubuntu-24.04` | 0.1.17 | bash | 19 ok, 0 failed |

A real Claude Code 2.1.318 too, on the real PC with the Herdr app's own
core (hc 0.1.18), through this PR's code merged in my fork's main: **New
workspace** with Claude answered `agent_started` in 1.2 s, the pane
showed in the snapshot as `claude`, `idle`, its chat read
`claude-transcript`, and closing it left the app's other terminals as
they were.

On upstream's code, without this PR, the same script on Windows gives
`{"agents":[]}` from `/api/agents` and `agent_pane_unsupported`.

Everything together on GitHub's hosted machines, this branch merged with
the other open Windows herdr-core branches on `f2bbee9e`, on my fork:
upstream's `ci.yml`
([run](https://github.com/photojupi/ovis/actions/runs/42078075843)):
`bun run check fast` passes (unit: 12253 pass, 0 fail, 39 skip), and so
do Integration tests, every Browser regression shard, Rust PTY host, the
herdr-core live contract and Native Windows install. That job's `Release
guards` step fails on a fork with no `v*` tags; with the tags it says
this is no release. `Native macOS session identity` got no `macos-15`
runner, twice; its test files ran in the whole unit suite below, with no
failure among them.

Linux, WSL Ubuntu, Bun 1.4.2, clean checkout of this commit on
`5b51cbe3`:

- `bun run typecheck` passes. `server/herdr-core/adapter.test.ts`,
`server/shell-agent.test.ts`, `server/agents-routes.test.ts`,
`server/agent-launch.test.ts`, `server/conventions.test.ts` and
`scripts/changelog-fold.test.ts`: 135 pass, 0 fail.
- Differential against `0011d8fd`, each version's real adapter over a
real unix socket with the same scripted core (a scratch script, not
committed): 24,440 combinations of own argv, foreground, pid, kind,
arguments and what comes up after typing, on `linux` and again on
`darwin`, gave the same answer (code and message), the same typed bytes
and the same hc calls: 0 mismatches. `server.agent_manifests` is the
same too. As a control, 1,504 of 2,000 of them differ under `platform:
"win32"`. An independent fuzz with injected faults, 12,000 cases each on
`linux`, `darwin` and `freebsd`: 0 mismatches.

Windows 11 (5.1.26100.9711, Bun 1.4.2):

- `bun run typecheck`: pass.
- `server/herdr-core/adapter.test.ts` cannot reach the adapter on
Windows at the base commit (5 pass, 64 fail: `setup()` gives a plain
path, which the adapter listens on and the client maps to a pipe). With
a temporary uncommitted patch of `setup()` to `adapterAddress`: the
agent tests alone 24 pass, 0 fail.
- `powershellNativeArgs`' output through real `powershell.exe` 5.1
(`-File`) into `node.exe`, no hc. Every string of length 0 to 5 over
`a`, space, `"`, `\` and a no-break space (3,906): the 3,174 accepted
arrive intact; of the 732 refused, the unrefused encoding would split or
change 525, and the other 207 hold only a no-break space and are refused
on 5.1's own test.
- cmd, which hc does not open by default: the real adapter and process
table with a real `cmd.exe` in a ConPTY of its own, behind a stand-in
for the core. A Bun binary, npm's `.cmd` shim and one reading `%~dp0`
each got 9 arguments back unchanged, among them `a&b` and `^caret`.

macOS: a real hc 0.1.17 and the real server on GitHub's `macos-14` and
`macos-26` (arm64) and `macos-15-intel`, with a stand-in agent:
`.github/platform-check/e2e.ts` on my fork gives 19 ok, 0 failed on each
([arm64
run](https://github.com/photojupi/ovis/actions/runs/42084111704), [Intel
run](https://github.com/photojupi/ovis/actions/runs/42078075706)): a
workspace opens, the terminal takes typing through the API and the
browser, the agent starts with `it's`, `$HOME`, `a\b`, `"q" r`, an empty
argument and `한글 인자` intact, and closing leaves nothing.
`server/herdr-core/live.test.ts` on that hc: 7 pass, 1 fail, the schema
test, which pins hc 0.1.14 as `ci.yml` does and fails without these
branches too. The whole unit suite on `macos-15-intel`, all these
branches merged against their base: the base's 41 failures are the same
on both sides (`tar --transform`, workflow files and the like, none in a
file these branches change but `server/session-history.test.ts`, whose
failures match by name); a first, slower run's two extra timeouts, in
files these branches do not touch (`server/pi-delivery.test.ts`,
`server/windows-native.test.ts`), did not come back on the rerun. No
real MacBook: the Herdr app's own window on macOS was not checked.

Not run:

- A real Codex or any other real agent: only Claude Code ran for real,
as below.
- A cmd pane with a real hc, and the real Herdr app's window on macOS.
- `bun run check full` as one command, and the Windows VM and `eamac`
lanes, which are the maintainer's.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: devswha <devswha@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

herdr-core: show a PC's other machines through its own hc connection, not SSH bridges

1 participant