Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2026-10-27

- [host] Rust host phase 4a (#76, ADR 0004 Accepted): terminal panes run in the Rust host by default on Linux, macOS and Windows. `settings.nodeHost` (Settings → Advanced "Use the Node host", off; `WRAITHGRID_NODE_HOST=1` in tests) replaces `rustHost`: every config saved that as false, so it is dropped on load and everyone moves to Rust; a build without the binary runs the Node host. `bun run test:e2e` builds the Rust host first, so e2e runs the default; `rust-host.spec` checks both ways (files: src/shared/{types,schema}.ts, src/main/app.ts, src/renderer/components/SettingsView.tsx, tests/e2e/{rust-host,machines}.spec.ts, package.json, docs/adr/0004-rust-pty-host.md, docs/CONTEXT.md)
- [host] Rust host phase 3b (#76, ADR 0004): Windows builds carry `wraithgrid-host.exe` (`dist:win` builds it). Panes run in a ConPTY of our own (portable-pty quotes every argument, and an npm `.cmd` CLI comes as a ready `cmd.exe /d /s /c "…"` line): file and args quoted the way CreateProcess reads them, a ready line kept as is, stdio cut off from the host's log, the exit code from the process handle (no signals), kill = close the console then `taskkill /T /F` after the grace, as the Node host does. The host serves a named pipe (one instance per client, remote clients refused), checks the lock owner with OpenProcess, gets its token from BCryptGenRandom, and a Ctrl-C hook that can't be set (no console) never stops it; the ssh bridge stays Unix. `host-rs/tests/smoke.rs` starts the built host on every OS, connects over its socket or pipe, runs a pane that prints and one typed into, then shuts it down; CI's Windows job runs it with clippy. Settings → Advanced "Use the Rust host" shows on every OS (files: host-rs/src/{pty,main,server}.rs, host-rs/Cargo.{toml,lock}, host-rs/tests/smoke.rs, src/main/app.ts, src/renderer/components/SettingsView.tsx, package.json, electron-builder.yml, .github/workflows/build.yml, docs/CONTEXT.md)
- [host] Rust host phase 3a (#76, ADR 0004): macOS builds carry it too: `bun run dist:mac` builds one universal (arm64 + x64) `wraithgrid-host` (`scripts/build-mac-host.sh`, lipo) bundled in both apps and ad-hoc signed with them; Settings → Advanced "Use the Rust host" shows on macOS; CI's macOS job runs `cargo test` and the host parity suite. Windows is phase 3b: portable-pty always quotes arguments, so the ready `cmd.exe /d /s /c "…"` line of an npm `.cmd` CLI needs a ConPTY spawn of our own (files: scripts/build-mac-host.sh, package.json, electron-builder.yml, .github/workflows/build.yml, src/main/app.ts, src/renderer/components/SettingsView.tsx, docs/CONTEXT.md)
- [chat] A ↑ recall or a `/` pick no longer moves the caret a frame later (cause of the intermittent `chat-history.spec.ts` CI failures, e.g. `second promptline one`): the frame could land between a select-all and the typing that replaces it, so the text was added instead; the caret is now placed in a layout effect right after the text is in the box (files: src/renderer/components/chat/Composer.tsx)
Expand Down
2 changes: 1 addition & 1 deletion docs/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e
- `src/main/host-start.ts`: start a detached host (fd masking) + socket connect; used by app and bridge `src/main/pane-router.ts`: routes pane ops to the local host or a machine's (by workspace `machineId`)
- `src/main/spawn-resolve.ts`: pane spec → exact process (binary, args, cwd, env); local in ipc, remote in the bridge `src/main/ssh.ts`: `~/.ssh/config` hosts, ssh args, ssh child as a duplex stream
- `src/main/ipc.ts`: all IPC handlers; validates every payload with zod contracts `src/main/pane-host.ts`: GUI side of the pty host: find/start it, hello + token, create/attach/write/kill, version mismatch
- `host-rs/`: the Rust pty host (ADR 0004; phase 2/3: Settings → Advanced "Use the Rust host", off by default, Linux, macOS and Windows (own ConPTY spawn + named pipe there; `host-rs/tests/smoke.rs` runs the built host on every OS); `settings.rustHost` or `WRAITHGRID_RUST_HOST=1` → app.ts `startHost` runs the binary from resources (packaged) or `host-rs/target/release` (dev) instead of host.js; Linux packages bundle it via `extraResources`): `main.rs` lock/token/socket/signals, `server.rs` clients+panes (same frames as `src/host/protocol.ts`), `pty.rs` portable-pty + waitpid, `screen.rs` vt100 snapshot/text, `protocol.rs` frame validation; phase 5: `bridge.rs` = `wraithgrid-host stdio <ver>`, the ssh end on a Linux machine (starts/joins a host under `~/.wraithgrid/remote`, injects the token, turns `plan` frames into `create`), `plan.rs` = login-shell PATH, binary, folder, env on that machine; GUI side `src/main/remote-plan.ts` (spec → plan: the rules) + `remote-bin.ts` (preflight `uname -sm`, upload once per version to `~/.wraithgrid/bin`, fallback `wraithgrid host-stdio`), static musl binaries from `scripts/build-remote-hosts.sh` → `host-rs/target/remote` → resources `remote/`; `tests/host/` = parity suite vs the Node host (`bun run test:host`)
- `host-rs/`: the Rust pty host (ADR 0004; phase 4a: the default on Linux, macOS and Windows; Settings → Advanced "Use the Node host" (`settings.nodeHost`, `WRAITHGRID_NODE_HOST=1` in tests) falls back (own ConPTY spawn + named pipe there; `host-rs/tests/smoke.rs` runs the built host on every OS); `settings.rustHost` or `WRAITHGRID_RUST_HOST=1` → app.ts `startHost` runs the binary from resources (packaged) or `host-rs/target/release` (dev) instead of host.js; Linux packages bundle it via `extraResources`): `main.rs` lock/token/socket/signals, `server.rs` clients+panes (same frames as `src/host/protocol.ts`), `pty.rs` portable-pty + waitpid, `screen.rs` vt100 snapshot/text, `protocol.rs` frame validation; phase 5: `bridge.rs` = `wraithgrid-host stdio <ver>`, the ssh end on a Linux machine (starts/joins a host under `~/.wraithgrid/remote`, injects the token, turns `plan` frames into `create`), `plan.rs` = login-shell PATH, binary, folder, env on that machine; GUI side `src/main/remote-plan.ts` (spec → plan: the rules) + `remote-bin.ts` (preflight `uname -sm`, upload once per version to `~/.wraithgrid/bin`, fallback `wraithgrid host-stdio`), static musl binaries from `scripts/build-remote-hosts.sh` → `host-rs/target/remote` → resources `remote/`; `tests/host/` = parity suite vs the Node host (`bun run test:host`)
- `src/host/`: background pty host (ADR 0001), built as `out/main/host.js`, never imports electron: `index.ts` entry (lock, token, socket), `server.ts` panes + clients, `protocol.ts` NDJSON frames (zod), `paths.ts` socket/token paths per user-data dir, `screen.ts` headless xterm + serialize, `fds.ts` inherited-fd masking, `pty-manager.ts` spawns/kills pane processes (per-OS KillPolicy) `src/main/chat/`: chat panes (ADR 0002): `sessions.ts` runs one headless CLI per pane (pipes, JSON lines), `adapter.ts` registry, `claude.ts` stream-json ↔ ChatEvent (+ approvals), `agy.ts` agy's stream-json (no approvals/interrupt: `controls` event → the view restarts it), `codex.ts` `codex app-server` JSON-RPC, `images.ts` pasted images → temp files, `spawn.ts` masked fds/process group, `attach.ts` files from the attach picker, `transcript.ts` copies a conversation to another account's dir (claude transcript, or codex rollout under `sessions/YYYY/MM/DD`, #60) (switch account, #29); add a CLI = one adapter + `chat: true` in agent-clis
- `src/main/login.ts`: sign-in dialog backend, per CLI (`LOGIN_FLOWS`): claude `auth login` + `auth status --json`; codex `login --device-auth` (one-time code) + `login status`; `DIALOG_LOGIN_CLIS` in agent-clis says who gets the dialog `src/main/clipboard-image.ts`: clipboard image → temp file → text to paste (path, or `@path` for gemini/agy)
- `src/main/pane-env.ts`: builds a pane's env (per-CLI config dir var for agent panes; mod folders as `CLAUDE_CODE_PLUGIN_DIRS` for claude; strips the launching terminal's vars; UTF-8 locale fallback) `src/main/config-store.ts`: config.json load/save, atomic write, bad-file quarantine
Expand Down
1 change: 1 addition & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,4 @@ Entries dated 2026-09-29 are reconstructed from the code and commit history duri
- 2026-10-26 | Rust host phase 5 (#76): the window sends a `plan` (rules as data: candidates, env drop/set, login-shell probe) and the remote Rust bridge only does the machine-specific lookups; the binary is copied over ssh per version and must run (`--version`) before it is used, else `wraithgrid host-stdio` | One set of rules in TS, no Wraithgrid install needed on Linux machines, and a binary that can't run there (untested arm64) falls back instead of breaking the machine | Porting spawn-resolve/pane-env to Rust (two copies of the rules); a separate remote user-data dir shared with an installed Wraithgrid (its host may be another version)
- 2026-10-26 | A chat's live view is capped at 1000 items, dropping to 800 (#141) | Bounds memory and per-render work of a pane left open for days; the saved view already keeps only 400, so nothing a restart would show is lost | No cap (memory grows until the pane closes); virtualized list (bigger change, breaks find-in-page and follow)
- 2026-10-27 | Rust host phase 3 split: macOS first (3a), Windows after (3b) with a ConPTY spawn of our own | portable-pty always quotes each argument on Windows, so the ready `cmd.exe /d /s /c "…"` line for npm `.cmd` CLIs (platform.ts) can't pass through it; macOS needs no code change. One universal mac binary (lipo) serves both the arm64 and x64 apps | Splitting the line into argv (cmd.exe doesn't read `\"` escapes); a per-arch mac binary (two extraResources configs for one value)
- 2026-10-27 | Rust host default (#76 phase 4a) via a new `nodeHost` fallback setting, not a changed `rustHost` default; Node host and node-pty kept one release | Every config already stores `rustHost: false`, so a new default alone would move nobody; the unknown key is dropped on load. Windows and macOS were only checked in CI, so a one-switch way back stays until a release has run on Rust | Migrating `rustHost` false→true (overrides a deliberate opt-out that never existed); removing node-pty now (no fallback)
2 changes: 1 addition & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# TODO

## Now
- #76 Rust host: 3a macOS done (never run on a real Mac: only CI's parity suite); 3b Windows done on feat/rust-host-windows-20261027 (only CI's smoke test: never run in the real app on Windows, nor with a real npm claude.cmd); then 4 (default on, drop node-pty); phase 5 arm64 binary never run on real arm64; old `~/.wraithgrid/bin/wraithgrid-host-*` versions on remote machines are never removed
- #76 Rust host: 3a macOS done (never run on a real Mac: only CI's parity suite); 3b Windows done on feat/rust-host-windows-20261027 (only CI's smoke test: never run in the real app on Windows, nor with a real npm claude.cmd); 4a default on done (fallback: "Use the Node host"); 4b (remove the Node host and node-pty) after a release on 4a, ask the user first; phase 5 arm64 binary never run on real arm64; old `~/.wraithgrid/bin/wraithgrid-host-*` versions on remote machines are never removed
- CI flakes fixed 2026-10-27 (PR #152): chat.spec.ts:299 (sideways-wheel noise paused follow), chat-history.spec.ts (caret placed a frame late collapsed a select-all). Still seen once locally: responsive.spec.ts:131 (Settings nav heading not hidden in a small window), passed 3/3 on rerun
- Audit (2026-10-26) bugs: all fixed (#135–#138 PR #148, #139 PR #150, #140 PR #151). Not seen live: #138 cancelled pkexec prompt, #140 renderer-crash reattach, codex cancel/stop paths, agy first-start effort
- Audit (2026-10-26) perf: #141 done (PR #149); not measured live yet (a DevTools profile of a long streaming chat before/after would confirm)
Expand Down
17 changes: 13 additions & 4 deletions docs/adr/0004-rust-pty-host.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# ADR 0004: A Rust pty host

- Status: **Proposed** (2026-10-26), issue #76. Phase 1 added the Rust host next to the Node
one; phase 2 (Settings → Advanced, Linux bundle) lets a user turn it on. Off by default.
- Status: **Accepted** (2026-10-27), issue #76. Phases 1–3 and 5 done; phase 4a makes it the
default on Linux, macOS and Windows, with the Node host kept as a fallback setting until 4b
removes it and `node-pty`.

## Context

Expand Down Expand Up @@ -79,7 +80,14 @@ multi-webview (needed for the web preview, ADR 0003) is still unstable in Tauri
instead of `host.js` when `settings.rustHost` is on (`WRAITHGRID_RUST_HOST=1` in tests) and
the binary exists; otherwise the Node host, as before. The switch takes effect when a host
next starts.
- Phase 5: with the setting on, a Linux machine reached over ssh gets this version's static
- Phase 3: macOS gets one universal binary (lipo of arm64 + x64) for both apps; Windows runs
panes in a ConPTY of our own (portable-pty quotes every argument, and an npm `.cmd` CLI is a
ready `cmd.exe` line) and serves a named pipe. `host-rs/tests/smoke.rs` runs the built host
on every OS in CI.
- Phase 4a: `settings.nodeHost` (Settings → Advanced "Use the Node host", off;
`WRAITHGRID_NODE_HOST=1` in tests) replaces `rustHost`, which configs saved as false and is
dropped on load, so everyone moves to Rust; a build without the binary runs the Node host.
- Phase 5: with the Rust host in use, a Linux machine reached over ssh gets this version's static
musl binary (x64; arm64 built in CI, not yet run on hardware) in `~/.wraithgrid/bin`, copied
once and checked with `--version`; `wraithgrid-host stdio <version>` there joins (or starts)
a host under `~/.wraithgrid/remote`. The window sends a `plan` (`src/main/remote-plan.ts`):
Expand All @@ -93,7 +101,8 @@ multi-webview (needed for the web preview, ADR 0003) is still unstable in Tauri
2. **Behind a setting**: Settings → Advanced "Use the Rust host" (off), the binary in the
Linux bundle; dogfood.
3. **macOS + Windows**: ConPTY, named pipe, signing/notarization; x64 + arm64 builds.
4. **Default on**, then remove the Node host and `node-pty`.
4. **Default on** (4a, done), then remove the Node host and `node-pty` (4b) once a release has
run on it.
5. **Remote** (done behind the setting): the static binary on Linux ssh machines, so they
need no Wraithgrid installed.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"format": "prettier --write .",
"test": "vitest run",
"test:host": "electron-vite build && cargo build --release --manifest-path host-rs/Cargo.toml && vitest run --config vitest.host.config.ts",
"test:e2e": "electron-vite build && playwright test",
"test:e2e": "electron-vite build && cargo build --release --locked --manifest-path host-rs/Cargo.toml && playwright test",
"dist": "electron-vite build && electron-builder --publish never",
"dist:linux": "electron-vite build && cargo build --release --manifest-path host-rs/Cargo.toml && bash scripts/build-remote-hosts.sh && electron-builder --linux --publish never",
"dist:linux:portable": "bash scripts/dist-linux-docker.sh",
Expand Down
8 changes: 6 additions & 2 deletions src/main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,13 @@ const rustHostBin = ((): string | null => {
return existsSync(bin) ? bin : null
})()

/** Settings → Advanced → "Use the Rust host" (WRAITHGRID_RUST_HOST=1 in tests). */
/**
* The Rust host, the default since ADR 0004 phase 4, unless Settings → Advanced "Use the Node
* host" (WRAITHGRID_NODE_HOST=1 in tests) asks for the old one. A build without the binary
* runs the Node host anyway.
*/
const wantRustHost = (): boolean =>
store.get().settings.rustHost || process.env.WRAITHGRID_RUST_HOST === '1'
!store.get().settings.nodeHost && process.env.WRAITHGRID_NODE_HOST !== '1'

/** A static Rust host this build can copy to a Linux machine (scripts/build-remote-hosts.sh). */
async function remoteHostBinary(target: string): Promise<Buffer | null> {
Expand Down
24 changes: 13 additions & 11 deletions src/renderer/components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -631,20 +631,20 @@ function CliRow() {
)
}

/** The Rust background host (ADR 0004): off by default, in builds that have it. */
/** The background host (ADR 0004 phase 4): Rust by default, the Node one as a fallback. */
function RustHostRow() {
const actions = useActions()
const on = useApp((s) => s.config.settings.rustHost)
const on = useApp((s) => s.config.settings.nodeHost)
const available = useApp((s) => s.info.rustHost)
return (
<div className="srow">
<div>
<h3>Rust background host</h3>
<h3>Background host</h3>
<p className="ex">
Experimental. Runs terminal panes in the new background host written in Rust: about 5 MB
of memory while idle instead of about 110 MB. Chat panes are not affected. It is used the
next time the background host starts: quit with <em>Quit and stop agents</em>, then open
Wraithgrid again.
Terminal panes run in a background host written in Rust: about 5 MB of memory while idle.
If panes misbehave, go back to the older Node host (about 110 MB) and tell us. Chat panes
are not affected. It is used the next time the background host starts: quit with{' '}
<em>Quit and stop agents</em>, then open Wraithgrid again.
</p>
</div>
<div className="ctl">
Expand All @@ -653,13 +653,15 @@ function RustHostRow() {
className={`sw${on ? ' on' : ''}`}
role="switch"
aria-checked={on}
aria-labelledby="set-rust-host"
aria-labelledby="set-node-host"
disabled={!available}
onClick={() => actions.updateSettings({ rustHost: !on })}
onClick={() => actions.updateSettings({ nodeHost: !on })}
/>
<span id="set-rust-host">Use the Rust host</span>
<span id="set-node-host">Use the Node host</span>
</div>
{available ? null : <span className="hint">Not included in this build.</span>}
{available ? null : (
<span className="hint">This build has no Rust host: the Node host runs panes.</span>
)}
</div>
</div>
)
Expand Down
7 changes: 5 additions & 2 deletions src/shared/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,11 @@ export const settingsSchema = z.object({
gpuTerminal: z.boolean().default(false),
onWindowClose: z.enum(CLOSE_MODES).default('ask').catch('ask'),
cliAccess: z.boolean().default(false),
/** The Rust background host (ADR 0004 phase 2, Linux): used the next time a host starts. */
rustHost: z.boolean().default(false),
/**
* The Node host as a fallback (ADR 0004 phase 4: Rust is the default). Replaces `rustHost`,
* which every config saved as false: dropped on load, so everyone moves to Rust.
*/
nodeHost: z.boolean().default(false),
newPaneView: z.enum(PANE_VIEWS).default('chat').catch('chat'),
chatOnlyMigrated: z.boolean().default(false),
chatModel: z.string().regex(CHAT_MODEL_PATTERN).nullable().default(null).catch(null),
Expand Down
7 changes: 5 additions & 2 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,11 @@ export interface Settings {
onWindowClose: CloseMode
/** Let `wraithgrid list|read|send|wait|new|split|stop` reach panes (H6). Off by default. */
cliAccess: boolean
/** The Rust background host (ADR 0004 phase 2, Linux): used the next time a host starts. */
rustHost: boolean
/**
* Run terminal panes in the older Node background host instead of the Rust one, the default
* since ADR 0004 phase 4; used the next time a host starts. The fallback, until it goes.
*/
nodeHost: boolean
/** No longer read: chat-capable agent panes always open as chats (#110). */
newPaneView: PaneView
/** Saved terminal views of chat-capable panes were turned into chats once (#110). */
Expand Down
8 changes: 6 additions & 2 deletions tests/e2e/machines.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,12 @@ test('a workspace on another machine runs its panes there, over ssh, and they ou
squash(`CLAUDE_CONFIG_DIR=${remoteHome}/.wraithgrid/accounts/personal`)
)
await expect(win.locator('.ws-mach', { hasText: 'lab' })).toBeVisible()
// With the Rust host on, the lab runs the binary copied there, not `wraithgrid host-stdio`.
if (process.env.WRAITHGRID_RUST_HOST === '1') {
// With the Rust host (the default) and this build's remote binaries, the lab runs the binary
// copied there, not `wraithgrid host-stdio`.
if (
process.env.WRAITHGRID_NODE_HOST !== '1' &&
fs.existsSync(path.join(ROOT, 'host-rs/target/remote'))
) {
// Named after app.getVersion(), which is Electron's own when run unpackaged like here.
const binDir = path.join(remoteHome, '.wraithgrid/bin')
const [bin] = fs.readdirSync(binDir).filter((f) => f.startsWith('wraithgrid-host-'))
Expand Down
Loading
Loading