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] Old Rust hosts no longer pile up on ssh machines (#76): each connect touches this version's `~/.wraithgrid/bin/wraithgrid-host-<version>` (its date says when it was last used) and deletes other versions unused for 30 days, so two Wraithgrid versions sharing a machine don't copy theirs over again on every connect; a host still running a deleted one keeps running. `command find`, since a fish config can shadow `find` (this machine's does); tested for real in sh and fish (files: src/main/remote-bin.ts, tests/unit/remote-host.test.ts)
- [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)
Expand Down
1 change: 1 addition & 0 deletions docs/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e
- `src/renderer/lib/{ansi,ids,scheduling}.ts`, `src/renderer/layout/presets.ts`

## Known gotchas
- Commands run on an ssh machine go through the user's own shell and config (sh or fish): call tools as `command find` etc., since a fish function can shadow them (remote-bin.ts).
- Unpackaged (e2e), `app.getVersion()` is Electron's version (44.x), so the remote Rust host is copied as `wraithgrid-host-44.x`. A process the host starts detached must not keep inherited fds (Playwright's pipes, sshd's): app.close/ssh then never finish (host-start.ts masks them; bridge.rs marks them CLOEXEC).
- e2e fixtures with claude panes saved as terminals (`view` omitted = terminal) turn into chats on load unless `settings.chatOnlyMigrated: true` (#110); suites driving claude's own screen (background, cli, keys, mods, smoke, git-palette) set it.
- A reattached screen goes out as several `replay` data frames (`chunkReplay`, 512K chars each, #134), never one over `MAX_FRAME` (4 MB, a client drops the link); clients must treat consecutive replay frames as one screen (`wraithgrid attach` clears only before the first).
Expand Down
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); 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
- #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
- 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
11 changes: 10 additions & 1 deletion src/main/remote-bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,22 @@ export function remoteTarget(uname: string): string | null {
export const remoteBinPath = (version: string): string =>
`.wraithgrid/bin/wraithgrid-host-${version}`

/** Other versions unused this long are deleted from a machine; 0 days would make two
* Wraithgrid versions that share one machine copy theirs over again on every connect. */
export const UNUSED_DAYS = 30

/**
* POSIX sh and fish both run these: plain commands joined by `;` and `&&`. A binary counts
* only once it ran there (`--version`), so one that can't run falls back instead of failing.
* Found, it is touched (its date says when it was last used), and other versions unused for
* UNUSED_DAYS go; a host still running one keeps running, since Linux keeps a deleted file
* open for it.
*/
export const preflightCommand = (version: string): string => {
const bin = remoteBinPath(version)
return `uname -sm; test -x ${bin} && ${bin} --version >/dev/null && echo ${PRESENT}`
// `command`: a shell function named find (a fish config's) must not stand in for it.
const sweep = `command find .wraithgrid/bin -maxdepth 1 -type f -name 'wraithgrid-host-*' ! -name 'wraithgrid-host-${version}' -mtime +${UNUSED_DAYS} -delete 2>/dev/null`
return `uname -sm; test -x ${bin} && ${bin} --version >/dev/null && touch ${bin} && echo ${PRESENT}; ${sweep}`
}

export const uploadCommand = (version: string): string => {
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/remote-host.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
import { spawnSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { applyEnvPlan, buildPaneEnv, paneEnvPlan } from '../../src/main/pane-env'
import {
Expand Down Expand Up @@ -73,6 +85,36 @@ describe('the Rust host on a remote machine', () => {
expect(uploadCommand('1.2.3')).toMatch(/--version >\/dev\/null && mv -f/)
})

// Run for real by the shells a machine may have: sh, and fish when it is installed here.
const shells = ['sh', 'fish'].filter(
(sh) => spawnSync('sh', ['-c', `command -v ${sh}`]).status === 0
)
it.each(shells)('in %s: marks the version in use and deletes others unused for 30 days', (sh) => {
const home = mkdtempSync(path.join(os.tmpdir(), 'wg-remote-'))
try {
const dir = path.join(home, '.wraithgrid/bin')
mkdirSync(dir, { recursive: true })
const day = 86_400_000
const file = (name: string, ageDays: number): string => {
const f = path.join(dir, name)
writeFileSync(f, '#!/bin/sh\nexit 0\n', { mode: 0o700 })
const t = new Date(Date.now() - ageDays * day)
utimesSync(f, t, t)
return f
}
const current = file(path.basename(remoteBinPath('2.0.0')), 90)
const stale = file(path.basename(remoteBinPath('1.0.0')), 40)
const recent = file(path.basename(remoteBinPath('1.9.0')), 5)
const out = spawnSync(sh, ['-c', preflightCommand('2.0.0')], { cwd: home, encoding: 'utf8' })
expect(out.stdout).toContain('__WRAITHGRID_HOST_PRESENT__')
expect(existsSync(stale)).toBe(false)
expect(existsSync(recent)).toBe(true)
expect(Date.now() - statSync(current).mtimeMs).toBeLessThan(60_000)
} finally {
rmSync(home, { recursive: true, force: true })
}
})

const deps = (stdout: string, binary: Buffer | null, uploadCode = 0) => {
const runs: { command: string; input?: Buffer }[] = []
return {
Expand Down
Loading