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
4 changes: 4 additions & 0 deletions examples/browser-terminal/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
package-lock.json
*.log
82 changes: 82 additions & 0 deletions examples/browser-terminal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Browser Terminal

A full terminal for Agent OS VMs that runs in the browser, talking to a
[RivetKit](https://rivetkit.org) actor over its live connection — no bespoke
WebSocket server.

- **Left sidebar** — a list of VMs. Each is one Agent OS VM (one RivetKit actor
instance). The VM ids are kept in `localStorage`, so reopening the page — or
clicking a VM again — reconnects to the same running VM.
- **Tabs** — each VM can have multiple terminal sessions (PTY shells).
- **Reconnect** — because the actor keeps its VM (and shells) alive, a browser
that reconnects re-adopts the running shells and replays their scrollback.

This example is a **standalone project** (its own `node_modules`, installed from
published npm packages) so it runs without building the agent-os monorepo.

## Requirements

- `npm install` pulls everything from npm, including the prebuilt Agent OS
sidecar and the WASM coreutils in `@agentos-software/common`.
- Hosting a RivetKit actor locally uses a local Rivet engine + actor-host envoy.
The engine binary ships with `@rivetkit/engine-cli` (installed automatically),
and the native registry builder that wires it up lives in the sibling
`r6` rivetkit checkout. `npm run server` (via `run-server.mjs`) locates both;
set `AGENTOS_R6_ROOT` if your `r6` checkout is elsewhere.

## How it works

```
Browser (React + xterm.js) Node (RivetKit server, server.ts)
├─ useActor({ name: "shellVm", key }) ├─ actor "shellVm"
├─ openShell / writeShell / resize ───▶│ └─ vars.vm = AgentOs.create({ software:[common] })
├─ getShellBuffer / listShells │ actions: openShell/writeShell/resizeShell/
└─ useEvent("shellData" | "shellExit")◀┘ closeShell/listShells/getShellBuffer
onShellData ─▶ broadcast("shellData", { shellId, data })
```

The actor is a hand-written RivetKit actor (not the shipped `agentOS()` actor)
because the browser needs an interactive PTY channel. It wraps the public
`@rivet-dev/agentos-core` shell API (`openShell`, `onShellData`, `writeShell`,
`resizeShell`, `closeShell`) and streams PTY bytes to connected browsers as
`shellData` events (base64), broadcasting `shellExit` when a shell closes. The
VM handle is a non-serializable runtime resource, so it lives in the actor's
`vars`.

## Run

```bash
npm install
npm run dev # starts the RivetKit server (engine :6642) and Vite (:5173)
```

First boot spawns a local Rivet engine and registers the actor host, which takes
~30–40s; if the page loads before then it will retry until the host is ready.

Open http://localhost:5173, click **+ New VM**, then **+** to open a terminal
and start typing (`ls`, `echo hi | tr a-z A-Z`, `cd /tmp`, …).

Run the pieces separately if you prefer:

```bash
npm run server # RivetKit engine + actor host on :6642
npm run web # Vite dev server on :5173
```

Override the engine port with `PORT` and the web→engine endpoint with
`VITE_AGENTOS_ENDPOINT` (default `http://localhost:6642`).

## Notes

- Software: `@agentos-software/common` (provides `sh`) plus `everything`, `git`,
`http-get`, and `sqlite3` — roughly the tool set the agentos shell ships
(`curl`, `rg`, `grep`, `sed`, `jq`, `tree`, `git`, `sqlite3`, …). Agent OS has
no vim/editor package, so there is no in-VM editor.
- Output is pulled via a `readShell(shellId, offset)` action (an incremental
cursor), not pushed as events: in this local native-registry setup RivetKit
only flushes broadcasts to a connection when another connection is active, so a
lone browser driving its own shell never sees its own output over events.
- The VM shell is line-buffered (it only echoes a line on Enter), so the client
does **local echo + line editing** (printable chars, Backspace, Ctrl-C) and
suppresses the shell's own echo of the submitted line to avoid double display.
- Scrollback replayed on reconnect is bounded (256 KiB per shell) on the server.
12 changes: 12 additions & 0 deletions examples/browser-terminal/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent OS · Browser Terminal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
39 changes: 39 additions & 0 deletions examples/browser-terminal/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@rivet-dev/agentos-example-browser-terminal",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Browser terminal for Agent OS VMs — RivetKit actor + xterm.js.",
"scripts": {
"server": "node run-server.mjs",
"web": "vite",
"dev": "concurrently -k -n server,web -c blue,magenta \"node run-server.mjs\" \"vite\"",
"build": "vite build",
"preview": "vite preview",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@agentos-software/common": "0.3.0",
"@agentos-software/everything": "0.3.0",
"@agentos-software/git": "0.3.0",
"@agentos-software/http-get": "0.3.0",
"@agentos-software/sqlite3": "0.3.0",
"@rivet-dev/agentos": "0.2.4",
"@rivet-dev/agentos-core": "0.2.4",
"@rivetkit/react": "0.0.0-feat-dylib-actor-plugin.c44621f",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rivetkit": "0.0.0-feat-dylib-actor-plugin.c44621f"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.0",
"tsx": "^4.19.0",
"typescript": "^5.7.2",
"vite": "^6.0.0"
}
}
70 changes: 70 additions & 0 deletions examples/browser-terminal/run-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Launches server.ts under the sibling r6 rivetkit checkout's tsx loader +
// tsconfig, because the native registry builder it imports is TS source that
// uses `@/` path aliases. Also resolves the local Rivet engine binary and picks
// the engine port. This mirrors how packages/shell runs its actor-mode VM.
//
// Override the r6 checkout with AGENTOS_R6_ROOT and the port with PORT.

import { spawn } from "node:child_process";
import { existsSync, mkdtempSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));

const r6Root =
process.env.AGENTOS_R6_ROOT ?? "/home/nathan/.herdr/workspaces/agent-os/r6";
const r6Rk = join(r6Root, "rivetkit-typescript", "packages", "rivetkit");
const tsxLoader = join(r6Rk, "node_modules", "tsx", "dist", "loader.mjs");
const r6Tsconfig = join(r6Rk, "tsconfig.json");

if (!existsSync(tsxLoader)) {
console.error(
`Cannot find the r6 rivetkit tsx loader at ${tsxLoader}.\n` +
"This example needs the sibling `r6` rivetkit checkout to host the actor " +
"(the native registry builder is TS source there). Set AGENTOS_R6_ROOT.",
);
process.exit(1);
}

let engineBinary = process.env.RIVET_ENGINE_BINARY;
if (!engineBinary) {
try {
const pkg = require.resolve(
"@rivetkit/engine-cli-linux-x64-musl/package.json",
);
const candidate = join(dirname(pkg), "rivet-engine");
if (existsSync(candidate)) engineBinary = candidate;
} catch {
// platform package not installed; serve() will report binary_unavailable
}
}

const port = process.env.PORT ?? "6642";

const child = spawn(
process.execPath,
["--import", tsxLoader, join(here, "server.ts")],
{
cwd: r6Rk,
stdio: "inherit",
env: {
...process.env,
ESBK_TSCONFIG_PATH: r6Tsconfig,
TSX_TSCONFIG_PATH: r6Tsconfig,
RIVET_RUN_ENGINE_HOST: "127.0.0.1",
RIVET_RUN_ENGINE_PORT: port,
RIVET_TOKEN: process.env.RIVET_TOKEN ?? "dev",
RIVET_NAMESPACE: process.env.RIVET_NAMESPACE ?? "default",
AGENTOS_R6_ROOT: r6Root,
...(engineBinary ? { RIVET_ENGINE_BINARY: engineBinary } : {}),
RIVETKIT_STORAGE_PATH:
process.env.RIVETKIT_STORAGE_PATH ??
mkdtempSync(join(tmpdir(), "browser-terminal-")),
},
},
);
child.on("exit", (code) => process.exit(code ?? 0));
Loading
Loading