From af7227edde0add4595179f9f97e0b303bb86f32c Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:25:54 +0200 Subject: [PATCH 1/4] chore(dev): add linux dev container for macos hosts agentOS runs Linux-only, so the engine, sidecar, and VMs live in a container while the working tree stays on the host. node_modules, the cargo target, and the toolchain build trees are container-owned volumes so host edits are visible without dragging darwin-native binaries into Linux. --- docker/dev/Dockerfile | 60 +++++++++++++++++++++++++++++++++++++++ docker/dev/README.md | 58 ++++++++++++++++++++++++++++++++++++++ docker/dev/compose.yaml | 62 +++++++++++++++++++++++++++++++++++++++++ justfile | 62 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 docker/dev/Dockerfile create mode 100644 docker/dev/README.md create mode 100644 docker/dev/compose.yaml diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile new file mode 100644 index 0000000000..d218433c43 --- /dev/null +++ b/docker/dev/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1.10.0 +# +# Interactive dev container for agentOS. Unlike docker/build/*.Dockerfile, this +# image contains no repo source: the working tree is bind-mounted at /build and +# every build output lands in named volumes, so host edits are visible +# immediately and rebuilds stay incremental. +# +# The deno sysroot / LTO dance from the release build is deliberately omitted — +# dev binaries only need to run in this container, not on old glibc. +FROM ubuntu:24.04 + +ARG RUST_TOOLCHAIN=1.91.1 +ARG NODE_MAJOR=22 +ARG PNPM_VERSION=10.13.1 +ARG JUST_VERSION=1.36.0 + +ENV DEBIAN_FRONTEND=noninteractive \ + CARGO_HOME=/usr/local/cargo \ + RUSTUP_HOME=/usr/local/rustup \ + PNPM_HOME=/usr/local/pnpm \ + COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + PATH=/usr/local/cargo/bin:/usr/local/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates curl git make cmake ninja-build build-essential \ + pkg-config libssl-dev xz-utils unzip jq python3 python3-venv \ + clang lld binaryen file procps less vim-tiny; \ + rm -rf /var/lib/apt/lists/* + +# Node + pnpm +RUN set -eux; \ + curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -; \ + apt-get install -y --no-install-recommends nodejs; \ + corepack enable; \ + corepack prepare "pnpm@${PNPM_VERSION}" --activate; \ + rm -rf /var/lib/apt/lists/* + +# Rust: host target for the sidecar, wasm32-wasip1 + rust-src for the WASM +# command toolchain (scripts/patch-std.sh rebuilds std from source). +RUN set -eux; \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}" --profile minimal --no-modify-path; \ + rustup target add wasm32-wasip1; \ + rustup component add rust-src clippy rustfmt + +RUN set -eux; \ + arch="$(uname -m)"; \ + case "$arch" in \ + x86_64) just_arch=x86_64-unknown-linux-musl ;; \ + aarch64) just_arch=aarch64-unknown-linux-musl ;; \ + *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-${just_arch}.tar.gz" \ + | tar -xz -C /usr/local/bin just + +WORKDIR /build + +CMD ["sleep", "infinity"] diff --git a/docker/dev/README.md b/docker/dev/README.md new file mode 100644 index 0000000000..c9fa1fc50d --- /dev/null +++ b/docker/dev/README.md @@ -0,0 +1,58 @@ +# Dev container + +agentOS targets native Linux execution, so on macOS the engine, sidecar, and VMs +run in this container while the working tree stays on the host. The repo is +bind-mounted at `/build`; every build output lives in a named volume, so host +edits are visible immediately and no darwin-native binary is ever dragged into +Linux. + +This is a dev environment. For release artifacts see `docker/build/`. + +## First run + +```bash +just dev-up # build the image, start the container +just dev-bootstrap # pnpm install + WASM command set + sidecar (slow, one time) +``` + +`dev-bootstrap` builds the full default WASM tool set from source, which is the +long pole. It only needs re-running when the toolchain or software sources +change. + +## Daily use + +```bash +just dev-terminal-example # engine on :6420, Vite on :5173 +just dev-shell # interactive shell in the container +just dev-exec 'cargo check --workspace' +just dev-build-tabs # rebuild the inspector custom-tab bundle +just dev-down # stop +``` + +Open for the example UI. The hosted Rivet dashboard runs +in the host browser and points at , which serves the actor +gateway, `/inspector/*`, and custom-tab assets. + +## Layout + +| Path | Backing | Why | +| --- | --- | --- | +| `/build` | bind mount | host edits visible immediately | +| `/build/node_modules` | volume | host tree holds darwin binaries | +| `/build/target` | volume | Linux cargo output, survives recreation | +| `/build/toolchain/target`, `toolchain/c/*` | volumes | generated WASM/sysroot trees | +| cargo registry/git, pnpm store | volumes | warm caches across rebuilds | + +## Caveats + +- **Run `pnpm install` inside the container, not on the host.** Both write + package-level `node_modules` symlinks into the bind mount, and their + platform-specific optional dependencies differ. If you need host typechecks, + re-run `pnpm install` on the host afterwards. +- DNS is pinned to 1.1.1.1/8.8.8.8. Docker Desktop's embedded resolver + intermittently stops answering, which surfaces as an `EAI_AGAIN` storm during + install. +- `AGENTOS_SIDECAR_BIN` points at `/build/target/debug/agentos-sidecar`. Rebuild + it with `just dev-exec 'cargo build -p agentos-sidecar'`. +- The container publishes 6420 and 5173. Free them on the host first if another + engine is already bound. diff --git a/docker/dev/compose.yaml b/docker/dev/compose.yaml new file mode 100644 index 0000000000..15ab3ed5d3 --- /dev/null +++ b/docker/dev/compose.yaml @@ -0,0 +1,62 @@ +name: agentos-dev + +services: + dev: + build: + context: ../.. + dockerfile: docker/dev/Dockerfile + working_dir: /build + # Keep the container resident; drive it with `just dev-shell` / `docker compose exec`. + command: ["sleep", "infinity"] + stdin_open: true + tty: true + ports: + # RivetKit engine: actor gateway + /inspector/* + custom-tab assets. The + # hosted dashboard runs in the host browser and points at this port. + - "6420:6420" + # Vite dev server for examples/browser-terminal. + - "5173:5173" + # Docker Desktop's embedded resolver (192.168.65.7) intermittently stops + # answering, which surfaces as EAI_AGAIN storms during `pnpm install`. + dns: + - 1.1.1.1 + - 8.8.8.8 + environment: + # Unambiguous sidecar resolution; `just dev-bootstrap` builds this path. + AGENTOS_SIDECAR_BIN: /build/target/debug/agentos-sidecar + RUST_BACKTRACE: "1" + # Vite/RivetKit must listen on all interfaces to be reachable from the host. + HOST: 0.0.0.0 + # RivetKit binds the engine it spawns to loopback, which a published port + # cannot reach. This is the supported override for that bind address + # (rivetkit's `engineHost`); setting RIVET__GUARD__HOST does not work, + # because RivetKit passes its own value to the engine process. + RIVET_RUN_ENGINE_HOST: 0.0.0.0 + volumes: + - ../..:/build:cached + + # Host node_modules are darwin-native — mask every bind-mounted tree that + # holds platform binaries or build output with a container-owned volume. + - node_modules:/build/node_modules + - cargo_target:/build/target + - toolchain_target:/build/toolchain/target + + # Only `toolchain/c/vendor` is safe to mount: it holds the large wasi-sdk + # and llvm-project downloads and nothing deletes it. `sysroot`, `libs`, + # `.cache`, and `toolchain/vendor` are `rm -rf`'d wholesale by the build, + # which fails with EBUSY on a mount point — leave them on the bind mount. + - toolchain_c_vendor:/build/toolchain/c/vendor + + # Caches outside the working tree. + - cargo_registry:/usr/local/cargo/registry + - cargo_git:/usr/local/cargo/git + - pnpm_store:/root/.local/share/pnpm/store + +volumes: + node_modules: + cargo_target: + cargo_registry: + cargo_git: + pnpm_store: + toolchain_target: + toolchain_c_vendor: diff --git a/justfile b/justfile index df8f681194..84103df94b 100644 --- a/justfile +++ b/justfile @@ -341,3 +341,65 @@ test-bounded cmd='pnpm test': test-risky-probe *tests: ./.agent/scripts/run-risky-test-probe.sh "$@" + +# --- Dev container (docker/dev) --- +# +# agentOS runs Linux-only, so on macOS the engine, sidecar, and VMs live in a +# container while the working tree stays on the host. The repo is bind-mounted +# at /build; node_modules, cargo target, and the toolchain build trees are +# container-owned volumes, so host edits are visible instantly without dragging +# darwin-native binaries into Linux. +# +# First run: `just dev-up && just dev-bootstrap` (the bootstrap is slow — it +# builds the WASM command set and the sidecar from source). After that, +# `just dev-terminal-example` and edit TypeScript with hot reload. + +dev-compose := "docker compose -f docker/dev/compose.yaml" + +# Build the image and start the container. +dev-up: + {{dev-compose}} up -d --build + +dev-down: + {{dev-compose}} down + +# Drop into a shell inside the dev container. +dev-shell: + {{dev-compose}} exec dev bash + +# Run any command inside the dev container: `just dev-exec 'cargo check --workspace'` +dev-exec cmd: + {{dev-compose}} exec dev bash -lc "{{cmd}}" + +# One-time (slow) build of everything the engine needs from source. +dev-bootstrap: + {{dev-compose}} exec dev bash -lc '\ + set -euo pipefail; \ + echo "==> pnpm install"; \ + pnpm install --frozen-lockfile; \ + echo "==> WASM command set (long)"; \ + just toolchain-build; \ + just toolchain-copy-commands; \ + echo "==> software packages"; \ + pnpm --filter "@agentos-software/*" \ + --filter "!@agentos-software/codex" \ + --filter "!@agentos-software/codex-cli" \ + --filter "!@agentos-software/everything" build; \ + echo "==> agentos-sidecar (debug)"; \ + cargo build -p agentos-sidecar; \ + echo "==> workspace TypeScript + inspector tab bundle"; \ + pnpm --filter @rivet-dev/agentos-core --filter @rivet-dev/agentos-runtime-core build; \ + pnpm --filter @rivet-dev/agentos build; \ + echo "==> done"' + +# Serve examples/browser-terminal: engine on :6420, Vite on :5173. +dev-terminal-example: + {{dev-compose}} exec dev bash -lc '\ + cd examples/browser-terminal && \ + pnpm concurrently -k -n server,web -c blue,magenta \ + "tsx server.ts" \ + "vite --host 0.0.0.0"' + +# Rebuild the inspector custom-tab bundle (run after editing src/inspector-tabs). +dev-build-tabs: + {{dev-compose}} exec dev bash -lc 'pnpm --filter @rivet-dev/agentos build:tabs' From 78bc64d409f7e56b85c9bddcf5f85c9f696b906e Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:26:20 +0200 Subject: [PATCH 2/4] fix(agentos): repair inspector action transport, live events, and System tab - Buffer ReadableStream request bodies and strip `duplex`. Chrome requires HTTP/2 for request streaming, so every action POST failed with ERR_ALPN_NEGOTIATION_FAILED over cleartext HTTP/1.1. - Attach by actor id via `getForId(...).connect()`. The previous `useActor({name, id})` call passed a field that does not exist behind a type-suppressing cast, so no tab received live events. - Normalize omitted `args` in the process tree, which threw on render. - Give the VM state chip the bordered pill treatment its neighbours use. --- .../src/inspector-tabs/lib/fetch-compat.ts | 64 ++++++++++ .../agentos/src/inspector-tabs/lib/rivet.tsx | 111 +++++++++++++----- .../src/inspector-tabs/tabs/processes.tsx | 6 +- .../src/inspector-tabs/vm-status-badges.tsx | 15 ++- 4 files changed, 163 insertions(+), 33 deletions(-) create mode 100644 packages/agentos/src/inspector-tabs/lib/fetch-compat.ts diff --git a/packages/agentos/src/inspector-tabs/lib/fetch-compat.ts b/packages/agentos/src/inspector-tabs/lib/fetch-compat.ts new file mode 100644 index 0000000000..18d48828b2 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/lib/fetch-compat.ts @@ -0,0 +1,64 @@ +// Chrome treats a request with a `ReadableStream` body as a streaming upload, +// which it only permits over HTTP/2 — a cleartext `http://` engine is +// HTTP/1.1, so the request dies as ERR_ALPN_NEGOTIATION_FAILED before it is +// sent and the server never logs anything. rivetkit's browser client routes +// every action through a `Request` whose `.body` is a stream, so all inspector +// actions fail against a plain-http engine. Buffer such bodies back into a +// normal upload; over HTTPS this is a no-op in behavior. +// +// Action payloads are small (arguments only — file contents move as base64 in +// the JSON body), so buffering is bounded by the caller's own argument size. + +const STREAM_BODY_LIMIT_BYTES = 64 * 1024 * 1024; + +async function bufferStream( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.length; + if (total > STREAM_BODY_LIMIT_BYTES) { + throw new Error( + `inspector request body exceeds ${STREAM_BODY_LIMIT_BYTES} bytes; raise STREAM_BODY_LIMIT_BYTES in lib/fetch-compat.ts`, + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const out = new Uint8Array(new ArrayBuffer(total)); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out.buffer; +} + +let installed = false; + +/** Idempotent; safe to call before any client is constructed. */ +export function installStreamBodyFetchCompat(): void { + if (installed || typeof globalThis.fetch !== "function") return; + installed = true; + const original = globalThis.fetch.bind(globalThis); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body; + if (!(body instanceof ReadableStream)) return original(input, init); + const buffered = await bufferStream(body); + // `duplex` only exists to permit a stream body; carrying it over would + // re-trigger the same negotiation. + const { duplex: _duplex, ...rest } = init as RequestInit & { + duplex?: string; + }; + return original(input, { ...rest, body: buffered }); + }; +} diff --git a/packages/agentos/src/inspector-tabs/lib/rivet.tsx b/packages/agentos/src/inspector-tabs/lib/rivet.tsx index 591d9dcffb..488e97b8a9 100644 --- a/packages/agentos/src/inspector-tabs/lib/rivet.tsx +++ b/packages/agentos/src/inspector-tabs/lib/rivet.tsx @@ -1,36 +1,41 @@ // @rivetkit/react integration for the inspector. Builds ONE rivetkit client from // the iframe's { actorId, authToken } and shares it two ways: -// - `useActor({ name, id })` → a typed connection to the agent-os actor (used -// for the live `sessionEvent` stream via `.useEvent`). +// - a live connection to the agent-os actor, used for every broadcast stream +// (`sessionEvent`, `shellData`, `processOutput`, `vmBooted`, …). // - `setRivetClient()` → the same client backs the stateless `callAction` // transport used by React Query (lib/actor-client.ts). // // The client is created once auth is known (it needs the token for the gateway // URL segment), so this is a provider gated behind the init handshake. -import { createClient, createRivetKitWithClient } from "@rivetkit/react"; -import React, { createContext, type ReactNode, useContext, useMemo } from "react"; +// +// The inspector attaches to a PRE-PROVISIONED actor by id, so events come from +// `getForId(...).connect()`. `useActor` cannot serve this: its options are +// `{ name, key }` with no `id`, and an actor id is not a key — passing one +// resolves nothing and silently never opens a connection. +import { createClient } from "@rivetkit/react"; +import React, { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, + useRef, +} from "react"; import { setRivetClient } from "./actor-client"; import { INSPECTOR_ACTOR_NAME, type InspectorRegistry } from "./registry"; -// Non-generic factory so `ReturnType` stays clean (no instantiation -// expressions). The `` on createClient is what types the -// resulting `useActor` connection. -function createRk(authToken: string) { - const client = createClient({ - endpoint: window.location.origin, - token: authToken, - // Match the gateway's `x-rivet-encoding: json`. The client default is now - // `bare` (binary), whose decoder yields BigInt for integers and blows up on - // numeric coercion ("Cannot convert a BigInt value to a number"). - encoding: "json", - disableMetadataLookup: true, - }); - return { client, ...createRivetKitWithClient(client) }; +/** Minimal shape used from rivetkit's `ActorConn`. */ +interface AgentOsConn { + on(name: string, handler: (payload: unknown) => void): unknown; + dispose?: () => unknown; } -type RkBundle = ReturnType; +interface RivetValue { + conn: AgentOsConn | null; + actorId: string; +} -const RivetContext = createContext<{ bundle: RkBundle; actorId: string } | null>(null); +const RivetContext = createContext(null); export function RivetProvider({ actorId, @@ -41,20 +46,68 @@ export function RivetProvider({ authToken: string; children: ReactNode; }) { - const value = useMemo(() => { - const bundle = createRk(authToken); - setRivetClient(bundle.client, actorId); - return { bundle, actorId }; + const value = useMemo(() => { + const client = createClient({ + endpoint: window.location.origin, + token: authToken, + // Match the gateway's `x-rivet-encoding: json`. The client default is now + // `bare` (binary), whose decoder yields BigInt for integers and blows up on + // numeric coercion ("Cannot convert a BigInt value to a number"). + encoding: "json", + disableMetadataLookup: true, + }); + setRivetClient(client, actorId); + let conn: AgentOsConn | null = null; + try { + conn = client + .getForId(INSPECTOR_ACTOR_NAME, actorId) + .connect() as unknown as AgentOsConn; + } catch (error) { + // A dead event stream must not blank the whole inspector: queries still + // work, tabs just stop receiving live updates. + console.error("agentos inspector: failed to open actor connection", error); + } + return { conn, actorId }; }, [actorId, authToken]); + + useEffect(() => { + return () => { + try { + value.conn?.dispose?.(); + } catch (error) { + console.warn("agentos inspector: failed to dispose actor connection", error); + } + }; + }, [value]); + return {children}; } -/** Typed connection to the agent-os actor, resolved by id. */ +/** Subscribe to one actor broadcast for the life of the calling component. + * `handler` is read through a ref, so an inline closure does not resubscribe. */ +function useAgentOsEvent( + conn: AgentOsConn | null, + name: string, + handler: (payload: unknown) => void, +): void { + const handlerRef = useRef(handler); + handlerRef.current = handler; + useEffect(() => { + if (!conn) return; + const unsubscribe = conn.on(name, (payload) => handlerRef.current(payload)); + return () => { + if (typeof unsubscribe === "function") (unsubscribe as () => void)(); + }; + }, [conn, name]); +} + +/** Live connection to the agent-os actor, resolved by id. */ export function useAgentOsActor() { const ctx = useContext(RivetContext); if (!ctx) throw new Error("useAgentOsActor must be used within "); - return ctx.bundle.useActor({ - name: INSPECTOR_ACTOR_NAME, - id: ctx.actorId, - } as unknown as Parameters[0]); + const { conn } = ctx; + return { + useEvent: (name: string, handler: (payload: unknown) => void) => + useAgentOsEvent(conn, name, handler), + }; } diff --git a/packages/agentos/src/inspector-tabs/tabs/processes.tsx b/packages/agentos/src/inspector-tabs/tabs/processes.tsx index 474272d134..a11271248b 100644 --- a/packages/agentos/src/inspector-tabs/tabs/processes.tsx +++ b/packages/agentos/src/inspector-tabs/tabs/processes.tsx @@ -31,8 +31,10 @@ function flattenTree(nodes: ProcessTreeNode[], depth = 0, out: ProcessRow[] = [] ); for (const node of sorted) { const { children, ...info } = node; - out.push({ ...info, depth }); - flattenTree(children, depth + 1, out); + // Kernel rows omit `args` for processes with no argv (and older runtimes + // omit it entirely); normalize once here so every consumer can index it. + out.push({ ...info, args: info.args ?? [], depth }); + flattenTree(children ?? [], depth + 1, out); } return out; } diff --git a/packages/agentos/src/inspector-tabs/vm-status-badges.tsx b/packages/agentos/src/inspector-tabs/vm-status-badges.tsx index 399d60c04f..102cd2aaeb 100644 --- a/packages/agentos/src/inspector-tabs/vm-status-badges.tsx +++ b/packages/agentos/src/inspector-tabs/vm-status-badges.tsx @@ -8,7 +8,8 @@ // nothing, preserving stock behavior. import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; -import { CopyButton, relativeTime } from "./common"; +import { CopyButton, relativeTime, StatusDot } from "./common"; +import { cn } from "./lib/cn"; import { isMissingHealthAction } from "./lib/health"; import { useAgentOsActor } from "./lib/rivet"; import { healthQueryOptions } from "./lib/source"; @@ -83,7 +84,17 @@ export function VmStatusBadges({ return ( {chip ? ( - + // Pill, matching the warnings button beside it — bare text next to a + // bordered control reads as a rendering fault, not a status. + + {chip.label} ) : null} From 03532988a3096f33b46bae45f284ced83e9ad2a5 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:26:36 +0200 Subject: [PATCH 3/4] feat(agentos): add inspector Terminal tab with server-side shell replay Interactive PTY shells in the dashboard, rendered with Ghostty's VT parser. Input is raw passthrough: the kernel's line discipline owns echo, editing, and signal generation. Shell enumeration is server-owned. A shell outlives the page that opened it and holds the VM awake, so a client that lost its page must still be able to find it; `listShells` replaces any client-side tracking. The actor also keeps a headless emulator per shell, fed the same chunks it broadcasts, and serializes it back to ANSI on demand. Without it a reattaching client sees a blank pane until the guest repaints, which a full-screen program never does unprompted. Every broadcast carries a sequence number matching the snapshot's, so a client applies the repaint and then only the chunks past it. Also fixes `health.booted`, which was permanently false: racing a settled promise against an immediate value always loses, because `.catch()` still schedules a microtask. --- examples/browser-terminal/README.md | 9 +- packages/agentos/package.json | 3 +- packages/agentos/src/actor.ts | 116 +++- packages/agentos/src/index.ts | 1 + .../agentos/src/inspector-tabs/lib/source.ts | 142 ++++- .../agentos/src/inspector-tabs/lib/types.ts | 36 ++ packages/agentos/src/inspector-tabs/main.tsx | 6 + .../agentos/src/inspector-tabs/styles.css | 8 + .../src/inspector-tabs/tabs/terminal.tsx | 495 ++++++++++++++++++ packages/agentos/src/shell-replay.ts | 389 ++++++++++++++ packages/agentos/src/types.ts | 42 +- packages/agentos/tests/actor.test.ts | 1 + packages/agentos/tests/shell-replay.test.ts | 157 ++++++ pnpm-lock.yaml | 8 + 14 files changed, 1366 insertions(+), 47 deletions(-) create mode 100644 packages/agentos/src/inspector-tabs/tabs/terminal.tsx create mode 100644 packages/agentos/src/shell-replay.ts create mode 100644 packages/agentos/tests/shell-replay.test.ts diff --git a/examples/browser-terminal/README.md b/examples/browser-terminal/README.md index b1016f7da4..8471187929 100644 --- a/examples/browser-terminal/README.md +++ b/examples/browser-terminal/README.md @@ -71,10 +71,11 @@ Override the web→server endpoint with `VITE_AGENTOS_ENDPOINT` (default - Software: `@agentos-software/common` (provides `sh` + coreutils) plus `git`, `curl`, `ripgrep`, `jq`, and `sqlite3`. Agent OS has no vim/editor package, so there is no in-VM editor. -- The shipped actor has no `listShells` action and keeps no server-side - scrollback, so reconnect re-adopts saved shell ids and resumes **live** output - only (history from before the reload is not replayed). Stale ids (VM recreated) - are dropped after a liveness probe. +- This client predates the actor's `listShells` and `shellSnapshot` actions: it + re-adopts saved shell ids on reconnect and resumes **live** output only, so + history from before the reload is not replayed. Stale ids (VM recreated) are + dropped after a liveness probe. The dashboard Terminal tab uses the + server-owned list and server-rendered repaint instead. - 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. diff --git a/packages/agentos/package.json b/packages/agentos/package.json index 87de11445c..35b967f9b3 100644 --- a/packages/agentos/package.json +++ b/packages/agentos/package.json @@ -68,6 +68,7 @@ "@agentos-software/common": "workspace:*", "@rivet-dev/agentos-core": "workspace:*", "@rivetkit/react": "catalog:rivetkit", + "ghostty-web": "^0.4.0", "rivetkit": "catalog:rivetkit", "zod": "^4.1.11" }, @@ -85,9 +86,9 @@ }, "devDependencies": { "@agentos-software/coreutils": "workspace:*", - "@rivet-dev/agentos-test-harness": "workspace:*", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.2", + "@rivet-dev/agentos-test-harness": "workspace:*", "@tanstack/react-query": "^5.87.1", "@types/node": "^22.19.15", "@types/react": "^19.0.0", diff --git a/packages/agentos/src/actor.ts b/packages/agentos/src/actor.ts index 285a195fbb..68c2dd92d1 100644 --- a/packages/agentos/src/actor.ts +++ b/packages/agentos/src/actor.ts @@ -26,6 +26,7 @@ import { } from "rivetkit"; import { type DatabaseProvider, db, type RawAccess } from "rivetkit/db"; import { migrations } from "rivetkit/unstable/migrations"; +import { ShellReplayStore } from "./shell-replay.js"; import type { ActorData, ActorInlineExecutionOptions, @@ -48,6 +49,9 @@ import type { SerializableCronJobOptions, ShellDataPayload, ShellExitPayload, + ShellInfo, + ShellReplayMode, + ShellSnapshot, VmBootedPayload, VmShutdownPayload, } from "./types.js"; @@ -69,6 +73,8 @@ const ACTOR_SQLITE_INLINE_THRESHOLD = 64 * 1024; const ROOT_NAMESPACE = "agentos-root"; const PREVIEW_PATH_PATTERN = /^\/fetch\/([a-f0-9]{48})(\/.*)?$/; const MAX_SQLITE_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; +// Each open shell holds the VM awake, so this bounds an unbounded wake-lock. +const MAX_OPEN_SHELLS = 32; interface ActorSqliteMigration { readonly version: number; @@ -122,6 +128,19 @@ type AnyContext = ActorContext; interface RuntimeState { vm: Promise | null; + /** Set once `vm` resolves, cleared when it stops. Observe-only callers must + * read this instead of sampling `vm`: any `.then`/`.catch` on a settled + * promise still needs a microtask, so racing one against an immediate value + * always loses and reports a booted VM as not booted. */ + bootedVm: AgentOs | null; + /** Shells opened through this actor, in open order. Enumeration is + * server-owned: a client that lost its page (or never had one) must be able + * to find shells it is still keeping the VM awake for. Entries die with the + * VM, so this is deliberately in-memory. */ + openShells: Map; + /** Headless emulator per live shell, fed the same chunks this actor + * broadcasts, so a reattaching client can be repainted. */ + shellReplay: ShellReplayStore; subscribedSessions: Map void)[]>; onVmStop?: ( c: AnyContext, @@ -191,6 +210,9 @@ function runtimeFor(c: AnyContext): RuntimeState { if (!runtime) { runtime = { vm: null, + bootedVm: null, + openShells: new Map(), + shellReplay: new ShellReplayStore(), subscribedSessions: new Map(), vmCleanupRan: false, fetchStreamHolds: new Map(), @@ -363,6 +385,7 @@ async function ensureVm( } } vm.onCronEvent((cronEvent) => c.broadcast("cronEvent", cronEvent)); + runtime.bootedVm = vm; c.broadcast("vmBooted", {}); c.log.info({ msg: "agent-os vm booted", @@ -375,6 +398,7 @@ async function ensureVm( return await runtime.vm; } catch (error) { runtime.vm = null; + runtime.bootedVm = null; c.log.error({ msg: "agent-os vm bootstrap failed", actorId: c.actorId, @@ -392,6 +416,7 @@ async function disposeVm(c: AnyContext, reason: "sleep" | "destroy" | "error") { const runtime = runtimes.get(c.actorId); if (!runtime) return; const vm = runtime.vm; + runtime.bootedVm = null; runtimes.delete(c.actorId); for (const unsubscribers of runtime.subscribedSessions.values()) { for (const unsubscribe of unsubscribers) unsubscribe(); @@ -1296,12 +1321,29 @@ export function createAgentOsActions( ...args: Parameters ) => { const vm = await ensureVm(c, options); + const runtime = runtimeFor(c); + if (runtime.openShells.size >= MAX_OPEN_SHELLS) { + throw new Error( + `agent-os shell limit reached (${MAX_OPEN_SHELLS} open); close a shell or raise MAX_OPEN_SHELLS`, + ); + } + // Warm the emulator module before the PTY exists: from `terminal.open` + // to the subscription below, nothing may await, or the output produced + // in that window is lost to every consumer. + await runtime.shellReplay.warm(); const shell = vm.terminal.open(...args); + runtime.openShells.set(shell.shellId, { openedAt: Date.now() }); + runtime.shellReplay.open(shell.shellId, args[0]?.cols, args[0]?.rows); const unsubscribeData = vm.onShellData(shell.shellId, (event) => - c.broadcast("shellData", event), + c.broadcast("shellData", { + ...event, + seq: runtime.shellReplay.feed(event.shellId, event.data), + }), ); const unsubscribeStderr = vm.onShellStderr(shell.shellId, (event) => - c.broadcast("shellStderr", event), + // A diagnostic tap carrying bytes `shellData` already delivered, so + // it is never folded into replay and carries no sequence. + c.broadcast("shellStderr", { ...event, seq: 0 }), ); const unsubscribeExit = vm.onShellExit(shell.shellId, (event) => c.broadcast("shellExit", event), @@ -1309,6 +1351,8 @@ export function createAgentOsActions( void c .keepAwake( vm.terminal.wait(shell.shellId).finally(() => { + runtime.openShells.delete(shell.shellId); + runtime.shellReplay.close(shell.shellId); unsubscribeData(); unsubscribeStderr(); unsubscribeExit(); @@ -1323,6 +1367,17 @@ export function createAgentOsActions( ); return shell; }, + // Observe-only: enumerates shells this actor opened without booting a + // sleeping VM (a sleeping VM has none). Clients must not track shell ids + // themselves — a shell outlives the page that opened it and keeps the VM + // awake, so it has to stay discoverable. + listShells: async (c: AnyContext): Promise => { + const runtime = runtimes.get(c.actorId); + if (!runtime?.bootedVm) return []; + return [...runtime.openShells.entries()] + .map(([shellId, entry]) => ({ shellId, openedAt: entry.openedAt })) + .sort((a, b) => a.openedAt - b.openedAt); + }, writeShell: async ( c: AnyContext, ...args: Parameters @@ -1330,7 +1385,23 @@ export function createAgentOsActions( resizeShell: async ( c: AnyContext, ...args: Parameters - ) => (await ensureVm(c, options)).terminal.resize(...args), + ) => { + const result = (await ensureVm(c, options)).terminal.resize(...args); + runtimeFor(c).shellReplay.resize(args[0], args[1], args[2]); + return result; + }, + // Observe-only, like `listShells`: repainting a shell must never boot a + // VM, and a sleeping VM has no live shells to repaint. Returns `null` + // for an unknown shell so a client falls back to no replay. + shellSnapshot: async ( + c: AnyContext, + shellId: string, + mode: ShellReplayMode = "screen", + ): Promise => { + const runtime = runtimes.get(c.actorId); + if (!runtime?.bootedVm) return null; + return runtime.shellReplay.snapshot(shellId, mode); + }, closeShell: async ( c: AnyContext, ...args: Parameters @@ -1452,23 +1523,18 @@ export function createAgentOsActions( let booted = false; let sessions: number | null = null; let sidecar: RuntimeHealth["sidecar"] = null; - if (runtime?.vm) { - // Sample the boot promise without waiting on it: a VM still - // booting (or one whose boot failed) reports not-booted rather - // than blocking the observe-only action. - const vm = await Promise.race([ - runtime.vm.catch(() => null), - Promise.resolve(null), - ]); - if (vm) { - booted = true; - sessions = runtime.subscribedSessions.size; - const description = vm.sidecar.describe(); - sidecar = { - state: description.state, - activeVmCount: description.activeVmCount, - }; - } + // Read the settled VM synchronously: a VM still booting (or one whose + // boot failed) reports not-booted rather than blocking the + // observe-only action. + const vm = runtime?.bootedVm; + if (runtime && vm) { + booted = true; + sessions = runtime.subscribedSessions.size; + const description = vm.sidecar.describe(); + sidecar = { + state: description.state, + activeVmCount: description.activeVmCount, + }; } return { booted, @@ -1811,6 +1877,8 @@ export function createAgentOsActions( }, terminal: { open: flat.openShell, + list: flat.listShells, + snapshot: flat.shellSnapshot, write: flat.writeShell, resize: flat.resizeShell, wait: flat.waitShell, @@ -1900,6 +1968,8 @@ export function createAgentOsActions( writeProcessStdin: nested.process.writeStdin, closeProcessStdin: nested.process.closeStdin, openShell: nested.terminal.open, + listShells: nested.terminal.list, + shellSnapshot: nested.terminal.snapshot, writeShell: nested.terminal.write, resizeShell: nested.terminal.resize, closeShell: nested.terminal.close, @@ -2143,6 +2213,12 @@ const AGENTOS_INSPECTOR_CONFIG = { source: INSPECTOR_TABS_ASSET_DIR, icon: "folder-tree", }, + { + id: "terminal", + label: "Terminal", + source: INSPECTOR_TABS_ASSET_DIR, + icon: "terminal", + }, { id: "system", label: "System", diff --git a/packages/agentos/src/index.ts b/packages/agentos/src/index.ts index a29b487258..846f760012 100644 --- a/packages/agentos/src/index.ts +++ b/packages/agentos/src/index.ts @@ -54,6 +54,7 @@ export type { SerializableCronJobOptions, ShellDataPayload, ShellExitPayload, + ShellInfo, VmBootedPayload, VmShutdownPayload, } from "./types.js"; diff --git a/packages/agentos/src/inspector-tabs/lib/source.ts b/packages/agentos/src/inspector-tabs/lib/source.ts index 2afa6dfb61..4c3d4dfeb8 100644 --- a/packages/agentos/src/inspector-tabs/lib/source.ts +++ b/packages/agentos/src/inspector-tabs/lib/source.ts @@ -2,13 +2,14 @@ // action via the gateway (actor-client) and transforms the result into the // display type the component expects. The actor actions are thin wrappers over // the core `AgentOs` API, so core types are the wire types (see lib/types.ts). -import { keepPreviousData, queryOptions } from "@tanstack/react-query"; + import type { HistoryPage, PermissionResponseResult, PromptResult, SessionPage, } from "@rivet-dev/agentos-core"; +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; import { callAction, isInspectorActionError } from "./actor-client"; import type { FileContent, @@ -21,6 +22,9 @@ import type { RuntimeHealth, SessionInfo, SessionStreamEntry, + ShellInfo, + ShellReplayMode, + ShellSnapshot, SignedPreviewUrl, SoftwareBundle, SoftwareInfo, @@ -29,7 +33,11 @@ import type { } from "./types"; import { sessionIsLive } from "./types"; -const k = (actorId: string, ...rest: string[]) => ["agent-os", actorId, ...rest]; +const k = (actorId: string, ...rest: string[]) => [ + "agent-os", + actorId, + ...rest, +]; // ── Software ────────────────────────────────────────────────────────── function softwareInfoToBundle(info: SoftwareInfo): SoftwareBundle { @@ -62,7 +70,11 @@ export function decodeActionBytes(output: unknown): Uint8Array { // rivetkit's json decoder may already hand back a real Uint8Array. if (output instanceof Uint8Array) return output; // JSON encoding wraps Uint8Array as ["$Uint8Array", base64]. - if (Array.isArray(output) && output[0] === "$Uint8Array" && typeof output[1] === "string") { + if ( + Array.isArray(output) && + output[0] === "$Uint8Array" && + typeof output[1] === "string" + ) { const bin = atob(output[1]); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); @@ -113,7 +125,10 @@ export function mapSessionEvent(entry: SessionStreamEntry): TranscriptEvent { case "agent_message_chunk": case "agent_thought_chunk": { const content = e.content as { type?: string; text?: string } | undefined; - const text = content?.type === "text" && typeof content.text === "string" ? content.text : ""; + const text = + content?.type === "text" && typeof content.text === "string" + ? content.text + : ""; return { kind: entry.type === "user_message_chunk" @@ -134,7 +149,9 @@ export function mapSessionEvent(entry: SessionStreamEntry): TranscriptEvent { for (const c of e.content as Record[]) { if (!c || typeof c !== "object") continue; if (c.type === "content") { - const inner = c.content as { type?: string; text?: string } | undefined; + const inner = c.content as + | { type?: string; text?: string } + | undefined; if (inner?.type === "text" && typeof inner.text === "string") { outputParts.push(inner.text); } @@ -163,7 +180,9 @@ export function mapSessionEvent(entry: SessionStreamEntry): TranscriptEvent { const entries = Array.isArray(e.entries) ? (e.entries as Record[]).map((p) => ({ content: - typeof p?.content === "string" ? p.content : JSON.stringify(p?.content ?? ""), + typeof p?.content === "string" + ? p.content + : JSON.stringify(p?.content ?? ""), status: typeof p?.status === "string" ? p.status : undefined, })) : []; @@ -176,7 +195,9 @@ export function mapSessionEvent(entry: SessionStreamEntry): TranscriptEvent { text: `Mode changed to ${String(e.currentModeId ?? "unknown")}`, }; case "available_commands_update": { - const count = Array.isArray(e.availableCommands) ? e.availableCommands.length : 0; + const count = Array.isArray(e.availableCommands) + ? e.availableCommands.length + : 0; return { kind: "notice", seq, @@ -208,10 +229,15 @@ export function mapSessionEvent(entry: SessionStreamEntry): TranscriptEvent { /** Flatten `listSessions` into the pending-permission backfill: every request * a "waiting" session is blocked on. Durable, so requests raised while no * inspector was open still surface. */ -export function pendingPermissionsOf(sessions: SessionInfo[]): PendingPermissionDisplay[] { +export function pendingPermissionsOf( + sessions: SessionInfo[], +): PendingPermissionDisplay[] { return sessions.flatMap((session) => session.state.status === "waiting" - ? session.state.requests.map((request) => ({ ...request, sessionId: session.sessionId })) + ? session.state.requests.map((request) => ({ + ...request, + sessionId: session.sessionId, + })) : [], ); } @@ -222,7 +248,9 @@ export const agentOsSource = { queryOptions({ queryKey: k(actorId, "software"), queryFn: async () => - (await callAction("listSoftware", [])).map(softwareInfoToBundle), + (await callAction("listSoftware", [])).map( + softwareInfoToBundle, + ), }), processesQueryOptions: (actorId: string) => @@ -238,7 +266,8 @@ export const agentOsSource = { processTreeQueryOptions: (actorId: string) => queryOptions({ queryKey: k(actorId, "process-tree"), - queryFn: () => callAction("processTree", [], { timeoutMs: 10_000 }), + queryFn: () => + callAction("processTree", [], { timeoutMs: 10_000 }), refetchInterval: 5_000, }), @@ -254,9 +283,13 @@ export const agentOsSource = { // directory (does not exist / is a file); surface that as `null` so // callers can show "not found", distinct from `[]` (empty dir). queryFn: async (): Promise => { - const raw = await callAction("readdirEntries", [path], { - timeoutMs: 10_000, - }); + const raw = await callAction( + "readdirEntries", + [path], + { + timeoutMs: 10_000, + }, + ); if (raw === null) return null; const entries = raw .filter((e) => e.name !== "." && e.name !== "..") @@ -273,12 +306,17 @@ export const agentOsSource = { }; }); return entries.sort( - (a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name), + (a, b) => + Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name), ); }, }), - fileContentQueryOptions: (actorId: string, path: string | null, force = false) => + fileContentQueryOptions: ( + actorId: string, + path: string | null, + force = false, + ) => queryOptions({ queryKey: k(actorId, "file", path ?? "", force ? "force" : "guarded"), enabled: !!path, @@ -399,6 +437,39 @@ export const agentOsSource = { killProcess: (pid: number) => callAction("killProcess", [pid]), stopProcess: (pid: number) => callAction("stopProcess", [pid]), + // ── Terminal (terminal tab) ──────────────────────────────────────────── + // PTY output arrives on the `shellData` broadcast, not from these calls. + openShell: (cols: number, rows: number) => + callAction<{ shellId: string }>("openShell", [{ cols, rows }], { + timeoutMs: 30_000, + }), + writeShell: (shellId: string, data: string) => + callAction("writeShell", [shellId, data]), + resizeShell: (shellId: string, cols: number, rows: number) => + callAction("resizeShell", [shellId, cols, rows]), + closeShell: (shellId: string) => callAction("closeShell", [shellId]), + /** Server-rendered repaint for a shell that is already running. `null` when + * the runtime predates the action or the shell has no emulator, which the + * caller reads as "attach live, repaint nothing". */ + shellSnapshot: async ( + shellId: string, + mode: ShellReplayMode = "screen", + ): Promise => { + try { + return await callAction( + "shellSnapshot", + [shellId, mode], + { + timeoutMs: 8_000, + }, + ); + } catch (error) { + if (isInspectorActionError(error) && error.layer === "contract") + return null; + throw error; + } + }, + // ── Filesystem mutations (filesystem tab) ────────────────────────────── writeFile: (path: string, content: Uint8Array | string) => callAction("writeFile", [path, content], { timeoutMs: 30_000 }), @@ -409,12 +480,14 @@ export const agentOsSource = { // ── Session management (transcript tab) ──────────────────────────────── // Close ends the live agent process; the persisted transcript stays. - closeSession: (sessionId: string) => callAction("unloadSession", [{ sessionId }]), + closeSession: (sessionId: string) => + callAction("unloadSession", [{ sessionId }]), // ── Signed preview URLs (system tab) ─────────────────────────────────── createSignedPreviewUrl: (port: number, ttlSeconds: number) => callAction("createPreviewUrl", [port, ttlSeconds]), - expireSignedPreviewUrl: (token: string) => callAction("expirePreviewUrl", [token]), + expireSignedPreviewUrl: (token: string) => + callAction("expirePreviewUrl", [token]), }; // ── Runtime health (observe-only actions) ───────────────────────────── @@ -424,10 +497,35 @@ export const agentOsSource = { // (see lib/health.ts's isMissingHealthAction) — the status badges hide // themselves and the composer disables its Stop button. +/** Shells currently open on the actor. Observe-only: never boots the VM, and a + * sleeping VM correctly reports none. This is the source of truth for the + * terminal tab's shell strip — the tab must not remember ids itself, or a + * shell that outlives its page becomes unreachable while still holding the VM + * awake. Feature-detected like `health` so vendored bundles running against an + * older runtime degrade instead of erroring. */ +export const shellsQueryOptions = (actorId: string) => + queryOptions({ + queryKey: k(actorId, "shells"), + queryFn: async (): Promise => { + try { + return await callAction("listShells", [], { + timeoutMs: 8_000, + }); + } catch (error) { + if (isInspectorActionError(error) && error.layer === "contract") + return null; + throw error; + } + }, + refetchInterval: 10_000, + retry: false, + }); + export const healthQueryOptions = (actorId: string) => queryOptions({ queryKey: k(actorId, "runtime-health"), - queryFn: () => callAction("health", [], { timeoutMs: 8_000 }), + queryFn: () => + callAction("health", [], { timeoutMs: 8_000 }), refetchInterval: 5_000, // Contract-missing is permanent for this actor: never retry, and stop // polling (react-query keeps refetching errored queries otherwise). @@ -448,7 +546,8 @@ export const pendingPermissionsQueryOptions = (actorId: string) => const page = await callAction("listSessions", []); return pendingPermissionsOf(page.sessions); } catch (error) { - if (isInspectorActionError(error) && error.layer === "contract") return null; + if (isInspectorActionError(error) && error.layer === "contract") + return null; throw error; } }, @@ -467,7 +566,8 @@ export async function cancelPrompt(sessionId: string): Promise { await callAction("cancelPrompt", [{ sessionId }]); return true; } catch (error) { - if (isInspectorActionError(error) && error.layer === "contract") return false; + if (isInspectorActionError(error) && error.layer === "contract") + return false; throw error; } } diff --git a/packages/agentos/src/inspector-tabs/lib/types.ts b/packages/agentos/src/inspector-tabs/lib/types.ts index a72013e066..c49e6e960e 100644 --- a/packages/agentos/src/inspector-tabs/lib/types.ts +++ b/packages/agentos/src/inspector-tabs/lib/types.ts @@ -83,6 +83,22 @@ export interface ProcessExitPayload { pid: number; exitCode: number; } +/** Live `shellData` broadcast payload mirror — ordered PTY output for one + * shell. `data` is encoding-dependent; normalize with `decodeActionBytes`. + * `shellStderr` carries the same shape but is a diagnostic tap: rendering it + * alongside `shellData` double-prints every stderr byte. */ +export interface ShellDataPayload { + shellId: string; + data: unknown; + /** Orders this chunk against `ShellSnapshot.seq`; 0 when the runtime has no + * replay for the shell. */ + seq?: number; +} +/** Live `shellExit` broadcast payload mirror. */ +export interface ShellExitPayload { + shellId: string; + exitCode: number; +} /** Raw `createPreviewUrl` result. `path` is relative to the gateway * origin serving this iframe. */ @@ -201,3 +217,23 @@ export function sessionIsLive(session: SessionInfo): boolean { export interface VmShutdownPayload { reason?: "sleep" | "destroy" | "error" | string; } + +/** One live shell from the observe-only `listShells` action. */ +export interface ShellInfo { + shellId: string; + openedAt: number; +} + +/** How much history a `shellSnapshot` repaints. */ +export type ShellReplayMode = "none" | "screen" | "scrollback"; + +/** Server-rendered repaint for a running shell; `data` is ANSI to write + * verbatim into a fresh terminal. */ +export interface ShellSnapshot { + shellId: string; + mode: ShellReplayMode; + seq: number; + cols: number; + rows: number; + data: string; +} diff --git a/packages/agentos/src/inspector-tabs/main.tsx b/packages/agentos/src/inspector-tabs/main.tsx index 61c72513f5..e1d5dd35f7 100644 --- a/packages/agentos/src/inspector-tabs/main.tsx +++ b/packages/agentos/src/inspector-tabs/main.tsx @@ -2,6 +2,7 @@ import { dehydrate, hydrate, QueryClient, QueryClientProvider } from "@tanstack/ import { lazy, type ComponentType, StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { isInspectorActionError, tabIdFromUrl } from "./lib/actor-client"; +import { installStreamBodyFetchCompat } from "./lib/fetch-compat"; import { RivetProvider } from "./lib/rivet"; import { PermissionPrompts } from "./permission-prompts"; import { TabBoundary } from "./tab-boundary"; @@ -9,6 +10,9 @@ import React from "react"; import "./styles.css"; +// Must run before any rivetkit client is constructed. +installStreamBodyFetchCompat(); + // Tab registry: id → lazy component. Add a tab here + register the same id in // actor.ts `inspectorTabs` pointing `source` at this shared asset dir. const TABS: Record Promise<{ default: ComponentType<{ actorId: string }> }>> = { @@ -18,6 +22,8 @@ const TABS: Record Promise<{ default: ComponentType<{ actorId: str import("./tabs/filesystem").then((m) => ({ default: m.FilesystemTabConnected })), system: () => import("./tabs/system").then((m) => ({ default: m.SystemTabConnected })), + terminal: () => + import("./tabs/terminal").then((m) => ({ default: m.TerminalTabConnected })), }; // Hosts vendor built copies of this bundle and pin their tab-id config at diff --git a/packages/agentos/src/inspector-tabs/styles.css b/packages/agentos/src/inspector-tabs/styles.css index 8d456fa085..40c41e9881 100644 --- a/packages/agentos/src/inspector-tabs/styles.css +++ b/packages/agentos/src/inspector-tabs/styles.css @@ -62,3 +62,11 @@ body { font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; -webkit-font-smoothing: antialiased; } + +/* ghostty-web keeps a 1x1 transparent textarea to receive keystrokes and IME + composition. `opacity: 0` does not suppress the browser's own caret, which + paints at the pane's top-left corner and reads as a second cursor beside the + canvas-drawn one. */ +.agentos-terminal-pane textarea { + caret-color: transparent; +} diff --git a/packages/agentos/src/inspector-tabs/tabs/terminal.tsx b/packages/agentos/src/inspector-tabs/tabs/terminal.tsx new file mode 100644 index 0000000000..ee2f02cb4e --- /dev/null +++ b/packages/agentos/src/inspector-tabs/tabs/terminal.tsx @@ -0,0 +1,495 @@ +// Interactive PTY shells in the VM, rendered with Ghostty's VT parser (WASM) +// behind an xterm.js-shaped API. Input is raw passthrough: the kernel's line +// discipline owns echo, editing, and signal generation, so a local-echo layer +// would double-print and break raw-mode apps (vim, reedline). +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { FitAddon, init as initGhostty, Terminal } from "ghostty-web"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActionErrorNote, + AgentOsEmpty, + IconButton, + PlusIcon, + UnsupportedAction, +} from "../common"; +import { cn } from "../lib/cn"; +import { useAgentOsActor } from "../lib/rivet"; +import { agentOsSource, decodeActionBytes, shellsQueryOptions } from "../lib/source"; +import type { ShellDataPayload, ShellExitPayload, ShellInfo } from "../lib/types"; +import { VmBootGate } from "../vm-boot-gate"; +import { VmStatusBadges } from "../vm-status-badges"; +import React from "react"; + +const DEFAULT_COLS = 80; +const DEFAULT_ROWS = 24; +const SCROLLBACK_LINES = 5_000; +const MAX_SHELLS = 8; +// Output that arrives between `openShell` resolving and the pane mounting is +// held here. Bounded: a shell that floods before its pane exists drops the +// oldest bytes rather than growing without limit. +const MAX_PENDING_BYTES = 256 * 1024; +// Unsent PTY input held while an earlier write is in flight. +const MAX_PENDING_INPUT_CHARS = 1024 * 1024; + +/** Ghostty's WASM module is process-wide and loads once. `init()` is + * idempotent, but keeping one promise avoids racing instantiations. */ +let ghosttyReady: Promise | undefined; +function loadGhostty(): Promise { + ghosttyReady ??= initGhostty(); + return ghosttyReady; +} + +function useGhostty(): { ready: boolean; error: unknown } { + const [state, setState] = useState<{ ready: boolean; error: unknown }>({ + ready: false, + error: null, + }); + useEffect(() => { + let cancelled = false; + loadGhostty().then( + () => !cancelled && setState({ ready: true, error: null }), + (error) => !cancelled && setState({ ready: false, error }), + ); + return () => { + cancelled = true; + }; + }, []); + return state; +} + +// ── Output router ────────────────────────────────────────────────────────── +/** Routes `shellData` broadcasts to whichever pane owns that shell, buffering + * for shells whose pane has not mounted yet. Lives in a ref (not state): PTY + * output must never drive a React render. */ +interface PendingChunk { + seq: number; + bytes: Uint8Array; +} + +class OutputRouter { + private writers = new Map void>(); + private pending = new Map(); + + push(shellId: string, seq: number, bytes: Uint8Array): void { + const writer = this.writers.get(shellId); + if (writer) { + writer(bytes); + return; + } + const buffered = this.pending.get(shellId) ?? []; + buffered.push({ seq, bytes }); + let total = buffered.reduce((sum, chunk) => sum + chunk.bytes.length, 0); + while (total > MAX_PENDING_BYTES && buffered.length > 1) { + total -= buffered.shift()?.bytes.length ?? 0; + } + this.pending.set(shellId, buffered); + } + + /** Attaches a writer and flushes what it missed. `sinceSeq` drops chunks a + * server snapshot already painted; the actor stamps every broadcast from the + * same counter the snapshot reports, so the two can neither gap nor overlap. */ + attach( + shellId: string, + writer: (bytes: Uint8Array) => void, + sinceSeq = 0, + ): () => void { + this.writers.set(shellId, writer); + const buffered = this.pending.get(shellId); + if (buffered) { + this.pending.delete(shellId); + for (const chunk of buffered) { + if (chunk.seq > sinceSeq) writer(chunk.bytes); + } + } + return () => { + if (this.writers.get(shellId) === writer) this.writers.delete(shellId); + }; + } + + forget(shellId: string): void { + this.writers.delete(shellId); + this.pending.delete(shellId); + } +} + +// ── Input queue ──────────────────────────────────────────────────────────── +/** Serializes PTY input per shell. Each keystroke would otherwise be its own + * in-flight request, and concurrent requests reach the PTY in completion + * order, not typing order — which transposes characters. Queued bytes also + * coalesce into one write, so a burst (or a paste) costs a single round-trip. */ +class InputQueue { + private queues = new Map(); + + constructor( + private readonly send: (shellId: string, data: string) => Promise, + private readonly onError: (error: unknown) => void, + ) {} + + write(shellId: string, data: string): void { + const queue = this.queues.get(shellId) ?? { pending: "", flushing: false }; + if (queue.pending.length + data.length > MAX_PENDING_INPUT_CHARS) { + this.onError( + new Error( + `Pending terminal input exceeds ${MAX_PENDING_INPUT_CHARS} characters; raise MAX_PENDING_INPUT_CHARS in tabs/terminal.tsx`, + ), + ); + return; + } + queue.pending += data; + this.queues.set(shellId, queue); + if (!queue.flushing) void this.flush(shellId, queue); + } + + private async flush( + shellId: string, + queue: { pending: string; flushing: boolean }, + ): Promise { + queue.flushing = true; + try { + while (queue.pending) { + const chunk = queue.pending; + queue.pending = ""; + await this.send(shellId, chunk); + } + } catch (error) { + // Drop the rest: replaying input after a failed write would deliver it + // out of order, which is what this queue exists to prevent. + queue.pending = ""; + this.onError(error); + } finally { + queue.flushing = false; + } + } + + forget(shellId: string): void { + this.queues.delete(shellId); + } +} + +// ── Terminal pane ────────────────────────────────────────────────────────── +function terminalTheme(): Record { + const dark = document.documentElement.classList.contains("dark"); + return dark + ? { background: "#0b0e14", foreground: "#c7d0e0", cursor: "#c7d0e0" } + : { background: "#ffffff", foreground: "#1f2328", cursor: "#1f2328" }; +} + +// The kernel answers a cursor-position query (ESC[6n) itself with a synthetic +// report, because a converged PTY may have no emulator on the master side +// (crates/kernel/src/pty.rs). With a real emulator attached, Ghostty answers +// too — and the second report lands in the guest's input stream as garbage. +// Drop ours; the kernel's already went through. +const CURSOR_POSITION_REPORT = /\x1b\[\d+;\d+R/g; + +function TerminalPane({ + shellId, + active, + router, + onInput, + onResize, + onError, +}: { + shellId: string; + active: boolean; + router: OutputRouter; + onInput: (data: string) => void; + onResize: (cols: number, rows: number) => void; + onError: (error: unknown) => void; +}) { + const containerRef = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + + // Handlers change identity every render; the terminal is built once per + // shell, so read them through refs instead of rebuilding it. + const onInputRef = useRef(onInput); + const onResizeRef = useRef(onResize); + const onErrorRef = useRef(onError); + onInputRef.current = onInput; + onResizeRef.current = onResize; + onErrorRef.current = onError; + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const term = new Terminal({ + cursorBlink: true, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', + fontSize: 13, + scrollback: SCROLLBACK_LINES, + theme: terminalTheme(), + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(container); + termRef.current = term; + fitRef.current = fit; + fit.fit(); + fit.observeResize(); + + term.onData((data) => { + const filtered = data.replace(CURSOR_POSITION_REPORT, ""); + if (filtered) onInputRef.current(filtered); + }); + term.onResize(({ cols, rows }) => onResizeRef.current(cols, rows)); + + // Repaint from the server before taking live output. This pane is built + // from scratch on every tab switch — the dashboard reboots the iframe — + // and a full-screen program (vim, less) never repaints unprompted, so + // without a snapshot a reattached shell shows a blank screen. Output that + // arrives while the snapshot is in flight stays buffered in the router + // and is replayed after it, minus whatever the snapshot already covered. + let detach: (() => void) | null = null; + let disposed = false; + const writer = (bytes: Uint8Array) => term.write(bytes); + void agentOsSource.shellSnapshot(shellId, "screen").then( + (snapshot) => { + if (disposed) return; + if (snapshot?.data) term.write(snapshot.data); + detach = router.attach(shellId, writer, snapshot?.seq ?? 0); + }, + (error) => { + if (disposed) return; + // Attach anyway: live output is still worth showing, but the failed + // repaint means the pane starts blank, so say so. + detach = router.attach(shellId, writer); + onErrorRef.current(error); + }, + ); + + return () => { + disposed = true; + detach?.(); + fit.dispose(); + term.dispose(); + termRef.current = null; + fitRef.current = null; + }; + }, [shellId, router]); + + // Hidden panes have no layout box, so their fit is a no-op while inactive; + // re-fit and take focus when this one comes forward. + useEffect(() => { + if (!active) return; + fitRef.current?.fit(); + termRef.current?.focus(); + }, [active]); + + return ( +
+ ); +} + +// ── Tab ──────────────────────────────────────────────────────────────────── +interface ShellTab { + shellId: string; + title: string; +} + +export function TerminalTabConnected({ actorId }: { actorId: string }) { + // Opening a shell boots the VM and then pins it awake for as long as the + // shell lives, so never do it just because someone clicked the tab. + return ( + + + + ); +} + +function TerminalTab({ actorId }: { actorId: string }) { + const ghostty = useGhostty(); + const routerRef = useRef(new OutputRouter()); + const router = routerRef.current; + + const queryClient = useQueryClient(); + // The actor owns the shell list; the tab never remembers ids. A shell + // outlives the page that opened it and holds the VM awake, so it has to + // stay discoverable from a fresh page. + const shellsQuery = useQuery(shellsQueryOptions(actorId)); + const [error, setError] = useState(null); + + const shells = useMemo( + () => + (shellsQuery.data ?? []).map((shell, i) => ({ + shellId: shell.shellId, + title: `shell ${i + 1}`, + })), + [shellsQuery.data], + ); + + const [selectedId, setSelectedId] = useState(null); + // Derived, not synced: a selection that no longer exists falls back to the + // newest shell during render rather than through an effect. + const activeId = + selectedId && shells.some((s) => s.shellId === selectedId) + ? selectedId + : (shells[shells.length - 1]?.shellId ?? null); + + const refreshShells = useCallback( + () => + queryClient.invalidateQueries({ + queryKey: shellsQueryOptions(actorId).queryKey, + }), + [actorId, queryClient], + ); + + const dropShell = useCallback( + (shellId: string) => { + router.forget(shellId); + inputRef.current?.forget(shellId); + queryClient.setQueryData( + shellsQueryOptions(actorId).queryKey, + (prev: ShellInfo[] | null | undefined) => + prev ? prev.filter((s) => s.shellId !== shellId) : prev, + ); + void refreshShells(); + }, + [actorId, queryClient, refreshShells, router], + ); + + const actor = useAgentOsActor(); + const useAgentEvent = actor.useEvent as ( + name: string, + handler: (payload: unknown) => void, + ) => void; + // Only `shellData` renders. `shellStderr` carries the same bytes as a + // diagnostic tap — subscribing to both double-prints stderr. + useAgentEvent("shellData", (raw) => { + const payload = raw as ShellDataPayload | undefined; + if (!payload || typeof payload.shellId !== "string") return; + const bytes = decodeActionBytes(payload.data); + if (bytes.length > 0) router.push(payload.shellId, payload.seq ?? 0, bytes); + }); + useAgentEvent("shellExit", (raw) => { + const payload = raw as ShellExitPayload | undefined; + if (!payload || typeof payload.shellId !== "string") return; + dropShell(payload.shellId); + }); + + const openShell = useMutation({ + mutationFn: () => agentOsSource.openShell(DEFAULT_COLS, DEFAULT_ROWS), + onSuccess: ({ shellId }) => { + setError(null); + // Show the pane immediately instead of waiting for the next poll; + // output that lands before it mounts is held by the router. + queryClient.setQueryData( + shellsQueryOptions(actorId).queryKey, + (prev: ShellInfo[] | null | undefined) => + prev ? [...prev, { shellId, openedAt: Date.now() }] : prev, + ); + setSelectedId(shellId); + void refreshShells(); + }, + onError: setError, + }); + + const closeShell = useMutation({ + mutationFn: (shellId: string) => agentOsSource.closeShell(shellId), + onMutate: dropShell, + onError: setError, + }); + + const inputRef = useRef(null); + inputRef.current ??= new InputQueue(agentOsSource.writeShell, setError); + const input = inputRef.current; + + const write = useCallback( + (shellId: string, data: string) => input.write(shellId, data), + [input], + ); + const resize = useCallback((shellId: string, cols: number, rows: number) => { + agentOsSource.resizeShell(shellId, cols, rows).catch(setError); + }, []); + + if (ghostty.error) { + return ( + +
+ Terminal renderer failed to load. + +
+
+ ); + } + // `null` = this runtime predates the `listShells` action, so shells cannot + // be enumerated and the tab has no safe way to track them. + if (shellsQuery.data === null) return ; + + const atLimit = shells.length >= MAX_SHELLS; + return ( +
+
+ {shells.map((shell) => ( +
setSelectedId(shell.shellId)} + className={cn( + "flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors", + shell.shellId === activeId + ? "bg-muted text-foreground" + : "text-muted-foreground hover:bg-muted/50", + )} + > + {shell.title} + +
+ ))} + openShell.mutate()} + disabled={!ghostty.ready || openShell.isPending || atLimit} + > + + +
+ +
+
+ + {error ? : null} + +
+ {/* Constructing a Terminal before `init()` resolves throws; a + reattached shell list can arrive first. Output that lands + meanwhile is held by the router and flushed on mount. */} + {!ghostty.ready ? ( + Loading terminal renderer… + ) : shells.length === 0 ? ( + No shells open — click + to start one. + ) : ( + shells.map((shell) => ( + write(shell.shellId, data)} + onResize={(cols, rows) => resize(shell.shellId, cols, rows)} + onError={setError} + /> + )) + )} +
+
+ ); +} diff --git a/packages/agentos/src/shell-replay.ts b/packages/agentos/src/shell-replay.ts new file mode 100644 index 0000000000..4565b639c3 --- /dev/null +++ b/packages/agentos/src/shell-replay.ts @@ -0,0 +1,389 @@ +// Server-side terminal state for shell replay. +// +// A shell outlives the page that opened it, and the dashboard reboots the tab +// iframe on every tab switch. Without state on this side, a reattaching client +// sees a blank pane until the guest happens to repaint — which a full-screen +// program (vim, less) never does unprompted. So the actor keeps a headless +// Ghostty emulator per shell, fed from the same chunks it broadcasts, and +// serializes it back to ANSI on demand. +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import type { ShellReplayMode, ShellSnapshot } from "./types.js"; + +/** Emulators alive at once. Matches the actor's open-shell ceiling. */ +const MAX_REPLAY_SHELLS = 32; +/** Scrollback retained per shell. Bounds the `scrollback` snapshot mode and + * the emulator's own memory; the screen itself is bounded by cols × rows. */ +const MAX_SCROLLBACK_LINES = 1_000; +/** Rejects an absurd resize before it becomes a cols × rows allocation. */ +const MAX_COLS = 1_000; +const MAX_ROWS = 1_000; + +const DEFAULT_COLS = 80; +const DEFAULT_ROWS = 24; + +// ── WASM loading ─────────────────────────────────────────────────────────── +// ghostty-web's own loader resolves its inlined wasm against `self.location`, +// which does not exist outside a browser. Instantiating the shipped .wasm +// directly avoids both the missing global and a multi-megabyte base64 decode. +interface GhosttyModule { + createTerminal( + cols: number, + rows: number, + config?: { scrollbackLimit?: number }, + ): HeadlessTerminal; +} + +interface GhosttyCell { + codepoint: number; + fg_r: number; + fg_g: number; + fg_b: number; + bg_r: number; + bg_g: number; + bg_b: number; + flags: number; + width: number; + grapheme_len: number; +} + +interface HeadlessTerminal { + write(data: string | Uint8Array): void; + resize(cols: number, rows: number): void; + free(): void; + update(): number; + getViewport(): GhosttyCell[]; + getCursor(): { viewportX: number; viewportY: number; visible: number }; + getColors(): { + foreground: { r: number; g: number; b: number }; + background: { r: number; g: number; b: number }; + }; + getGraphemeString(row: number, col: number): string; + getScrollbackLength(): number; + getScrollbackLine(offset: number): GhosttyCell[] | null; + getScrollbackGraphemeString(offset: number, col: number): string; + isAlternateScreen(): boolean; + getMode(mode: number, isAnsi?: boolean): boolean; +} + +let ghosttyPromise: Promise | undefined; + +async function loadGhostty(): Promise { + ghosttyPromise ??= (async () => { + const require = createRequire(import.meta.url); + const { Ghostty } = await import("ghostty-web"); + const bytes = await readFile( + require.resolve("ghostty-web/ghostty-vt.wasm"), + ); + const { instance } = await WebAssembly.instantiate(bytes, { + env: { log: () => {} }, + }); + return new Ghostty(instance) as unknown as GhosttyModule; + })(); + return ghosttyPromise; +} + +// ── ANSI serialization ───────────────────────────────────────────────────── +const CELL_BOLD = 1; +const CELL_ITALIC = 2; +const CELL_UNDERLINE = 4; +const CELL_STRIKETHROUGH = 8; +const CELL_INVERSE = 16; +const CELL_INVISIBLE = 32; +const CELL_BLINK = 64; +const CELL_FAINT = 128; + +/** DEC private modes worth restoring: getting these wrong strands the guest in + * a state its client no longer agrees with — arrow keys emitting the wrong + * prefix, or a paste arriving unbracketed and executing as commands. Each is + * read back individually rather than inferred, so the exact mouse protocol + * survives too. Alt-screen (1049) and cursor visibility (25) are restored + * separately, in a fixed order relative to the repaint. */ +const RESTORED_DEC_MODES = [ + 1, // DECCKM — application cursor keys + 7, // DECAWM — autowrap + 1000, // mouse: click tracking + 1002, // mouse: button-event tracking + 1003, // mouse: any-event tracking + 1004, // focus in/out reporting + 1005, // mouse: UTF-8 coordinates + 1006, // mouse: SGR coordinates + 1015, // mouse: urxvt coordinates + 2004, // bracketed paste +]; + +interface CellStyle { + fg: number | null; + bg: number | null; + flags: number; +} + +const DEFAULT_STYLE: CellStyle = { fg: null, bg: null, flags: 0 }; + +function rgb(r: number, g: number, b: number): number { + return (r << 16) | (g << 8) | b; +} + +function styleOf( + cell: GhosttyCell, + defaultFg: number, + defaultBg: number, +): CellStyle { + const fg = rgb(cell.fg_r, cell.fg_g, cell.fg_b); + const bg = rgb(cell.bg_r, cell.bg_g, cell.bg_b); + return { + fg: fg === defaultFg ? null : fg, + bg: bg === defaultBg ? null : bg, + flags: cell.flags, + }; +} + +function sameStyle(a: CellStyle, b: CellStyle): boolean { + return a.fg === b.fg && a.bg === b.bg && a.flags === b.flags; +} + +/** Full SGR for a style, always prefixed with a reset so it never inherits + * attributes from whatever the client last rendered. */ +function sgr(style: CellStyle): string { + const params: number[] = [0]; + if (style.flags & CELL_BOLD) params.push(1); + if (style.flags & CELL_FAINT) params.push(2); + if (style.flags & CELL_ITALIC) params.push(3); + if (style.flags & CELL_UNDERLINE) params.push(4); + if (style.flags & CELL_BLINK) params.push(5); + if (style.flags & CELL_INVERSE) params.push(7); + if (style.flags & CELL_INVISIBLE) params.push(8); + if (style.flags & CELL_STRIKETHROUGH) params.push(9); + if (style.fg !== null) { + params.push( + 38, + 2, + (style.fg >> 16) & 0xff, + (style.fg >> 8) & 0xff, + style.fg & 0xff, + ); + } + if (style.bg !== null) { + params.push( + 48, + 2, + (style.bg >> 16) & 0xff, + (style.bg >> 8) & 0xff, + style.bg & 0xff, + ); + } + return `\x1b[${params.join(";")}m`; +} + +/** One row of cells → text plus SGR changes. `graphemeAt` supplies the full + * cluster for cells that carry combining marks. Trailing default-styled blanks + * are dropped in favour of an erase-to-end-of-line. */ +function serializeRow( + cells: GhosttyCell[], + cols: number, + defaultFg: number, + defaultBg: number, + graphemeAt: (col: number) => string, +): string { + let lastMeaningful = -1; + for (let x = 0; x < cols; x++) { + const cell = cells[x]; + if (!cell) continue; + const blank = cell.codepoint === 0 || cell.codepoint === 32; + if ( + !blank || + !sameStyle(styleOf(cell, defaultFg, defaultBg), DEFAULT_STYLE) + ) { + lastMeaningful = x; + } + } + if (lastMeaningful < 0) return "\x1b[0m\x1b[K"; + + let out = ""; + let current = DEFAULT_STYLE; + out += sgr(current); + for (let x = 0; x <= lastMeaningful; x++) { + const cell = cells[x]; + if (!cell) { + out += " "; + continue; + } + // Width 0 is the trailing half of a wide character; the preceding cell + // already emitted the whole glyph. + if (cell.width === 0) continue; + const style = styleOf(cell, defaultFg, defaultBg); + if (!sameStyle(style, current)) { + out += sgr(style); + current = style; + } + if (cell.grapheme_len > 0) { + out += graphemeAt(x); + } else if (cell.codepoint === 0) { + out += " "; + } else { + out += String.fromCodePoint(cell.codepoint); + } + } + return `${out}\x1b[0m\x1b[K`; +} + +// ── Store ────────────────────────────────────────────────────────────────── +interface ReplayEntry { + term: HeadlessTerminal; + cols: number; + rows: number; + seq: number; +} + +function clamp( + value: number | undefined, + fallback: number, + max: number, +): number { + if (!Number.isFinite(value) || value === undefined || value <= 0) + return fallback; + return Math.min(Math.floor(value), max); +} + +/** + * One headless emulator per live shell, fed the same bytes the actor + * broadcasts. All methods are no-ops for unknown shell ids so a shell opened + * before replay existed (or one whose emulator failed to start) degrades to + * the previous behaviour instead of failing the write path. + */ +export class ShellReplayStore { + private entries = new Map(); + private ghostty: GhosttyModule | null = null; + + /** Instantiates the WASM module. Callers must await this before opening a + * shell so that `open` itself can be synchronous: a PTY starts producing + * output the moment it exists, and any await between opening it and + * subscribing is a window where output is lost to every consumer. */ + async warm(): Promise { + this.ghostty ??= await loadGhostty(); + } + + open(shellId: string, cols?: number, rows?: number): void { + if (this.entries.has(shellId)) return; + if (this.entries.size >= MAX_REPLAY_SHELLS) { + throw new Error( + `agent-os shell replay limit reached (${MAX_REPLAY_SHELLS} emulators); close a shell or raise MAX_REPLAY_SHELLS in shell-replay.ts`, + ); + } + if (!this.ghostty) { + throw new Error("agent-os shell replay used before warm() resolved"); + } + const width = clamp(cols, DEFAULT_COLS, MAX_COLS); + const height = clamp(rows, DEFAULT_ROWS, MAX_ROWS); + const term = this.ghostty.createTerminal(width, height, { + scrollbackLimit: MAX_SCROLLBACK_LINES, + }); + // A freed terminal's handle is recycled without its screen being cleared, + // so a new emulator can start holding the previous shell's contents — and + // serve them to whoever attaches next. Reset before anything is fed in. + term.write("\x1bc"); + this.entries.set(shellId, { term, cols: width, rows: height, seq: 0 }); + } + + /** Feeds one broadcast chunk and returns its sequence number. Clients use + * it to drop chunks a snapshot already contains. Returns 0 when this shell + * has no emulator, which clients read as "no replay available". */ + feed(shellId: string, data: Uint8Array): number { + const entry = this.entries.get(shellId); + if (!entry) return 0; + entry.term.write(data); + return ++entry.seq; + } + + resize(shellId: string, cols: number, rows: number): void { + const entry = this.entries.get(shellId); + if (!entry) return; + entry.cols = clamp(cols, entry.cols, MAX_COLS); + entry.rows = clamp(rows, entry.rows, MAX_ROWS); + entry.term.resize(entry.cols, entry.rows); + } + + close(shellId: string): void { + const entry = this.entries.get(shellId); + if (!entry) return; + this.entries.delete(shellId); + entry.term.free(); + } + + has(shellId: string): boolean { + return this.entries.has(shellId); + } + + snapshot(shellId: string, mode: ShellReplayMode): ShellSnapshot | null { + const entry = this.entries.get(shellId); + if (!entry) return null; + const { term, cols, rows, seq } = entry; + const base = { shellId, seq, cols, rows }; + if (mode === "none") return { ...base, mode, data: "" }; + + term.update(); + const colors = term.getColors(); + const defaultFg = rgb( + colors.foreground.r, + colors.foreground.g, + colors.foreground.b, + ); + const defaultBg = rgb( + colors.background.r, + colors.background.g, + colors.background.b, + ); + const alternate = term.isAlternateScreen(); + + // RIS first: the client pane may be reused, and a repaint that inherits + // stale modes or attributes is worse than no repaint at all. + let out = "\x1bc"; + + // Scrollback is written first and scrolled off by the screen paint that + // follows, so it lands in the client's scrollback rather than on screen. + // It belongs to the primary screen, so it precedes the alt-screen switch. + if (mode === "scrollback" && !alternate) { + const available = Math.min( + term.getScrollbackLength(), + MAX_SCROLLBACK_LINES, + ); + for (let i = available; i > 0; i--) { + const offset = i - 1; + const line = term.getScrollbackLine(offset); + if (!line) continue; + out += serializeRow(line, cols, defaultFg, defaultBg, (col) => + term.getScrollbackGraphemeString(offset, col), + ); + out += "\r\n"; + } + } + + // Modes go on before the repaint so the client is already in the right + // screen when cells land; 1049 also clears, which would undo the paint. + if (alternate) out += "\x1b[?1049h"; + for (const decMode of RESTORED_DEC_MODES) { + out += term.getMode(decMode) ? `\x1b[?${decMode}h` : `\x1b[?${decMode}l`; + } + + // Rows are painted sequentially from home rather than by absolute + // address: each `\r\n` scrolls whatever preceded it into scrollback, + // which is what carries the dump above off-screen intact. + const viewport = term.getViewport(); + out += "\x1b[H"; + for (let y = 0; y < rows; y++) { + if (y > 0) out += "\r\n"; + const row = viewport.slice(y * cols, (y + 1) * cols); + out += serializeRow(row, cols, defaultFg, defaultBg, (col) => + term.getGraphemeString(y, col), + ); + } + + const cursor = term.getCursor(); + const cy = Math.min(Math.max(cursor.viewportY, 0), rows - 1) + 1; + const cx = Math.min(Math.max(cursor.viewportX, 0), cols - 1) + 1; + out += `\x1b[0m\x1b[${cy};${cx}H`; + out += cursor.visible ? "\x1b[?25h" : "\x1b[?25l"; + + return { ...base, mode, data: out }; + } +} diff --git a/packages/agentos/src/types.ts b/packages/agentos/src/types.ts index e53e96a5d8..c0c32bdeca 100644 --- a/packages/agentos/src/types.ts +++ b/packages/agentos/src/types.ts @@ -28,7 +28,9 @@ export interface VmShutdownPayload { export type ProcessOutputPayload = ProcessOutput; export type ProcessExitPayload = ProcessExit; -export type ShellDataPayload = ShellData; +/** `seq` orders this chunk against `ShellSnapshot.seq`; it is 0 when the shell + * has no server-side emulator and therefore no replay. */ +export type ShellDataPayload = ShellData & { seq: number }; export type ShellExitPayload = ShellExit; export type SerializableCronEvent = CronEvent; export type ActorData = @@ -153,6 +155,44 @@ export interface RuntimeHealth { stderrTail: RuntimeStderrLine[]; } +/** + * One live shell from the observe-only `listShells` action. A shell outlives + * the page that opened it and keeps the VM awake until closed, so enumeration + * is server-owned rather than tracked per client. + */ +export interface ShellInfo { + shellId: string; + /** Epoch ms when the shell was opened through this actor. */ + openedAt: number; +} + +/** + * How much of a shell's history a snapshot repaints. + * + * - `none` — sequence number only; the caller repaints nothing. + * - `screen` — the visible viewport, cursor, and terminal modes. + * - `scrollback` — the retained scrollback above the viewport, then `screen`. + */ +export type ShellReplayMode = "none" | "screen" | "scrollback"; + +/** + * Server-rendered repaint for a shell that is already running. `data` is an + * ANSI stream to write verbatim into a fresh terminal. + */ +export interface ShellSnapshot { + shellId: string; + mode: ShellReplayMode; + /** + * Sequence of the last `shellData` chunk folded into this snapshot. + * Broadcasts carry the same counter, so a client applies the snapshot and + * then only the chunks whose `seq` is greater — no gap, no double-render. + */ + seq: number; + cols: number; + rows: number; + data: string; +} + // --- Serializable cron action (excludes callback type) --- export type SerializableCronAction = diff --git a/packages/agentos/tests/actor.test.ts b/packages/agentos/tests/actor.test.ts index 570c38e118..90980db8ac 100644 --- a/packages/agentos/tests/actor.test.ts +++ b/packages/agentos/tests/actor.test.ts @@ -183,6 +183,7 @@ describe("agentOS actor", () => { expect(tabs?.filter((tab) => !tab.hidden).map((tab) => tab.id)).toEqual([ "transcript", "filesystem", + "terminal", "system", ]); expect(tabs?.filter((tab) => tab.hidden).map((tab) => tab.id)).toEqual([ diff --git a/packages/agentos/tests/shell-replay.test.ts b/packages/agentos/tests/shell-replay.test.ts new file mode 100644 index 0000000000..cbf2e04d1f --- /dev/null +++ b/packages/agentos/tests/shell-replay.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "vitest"; +import { ShellReplayStore } from "../src/shell-replay.js"; + +const COLS = 40; +const ROWS = 10; + +async function storeWith(...chunks: string[]): Promise { + const store = new ShellReplayStore(); + await store.warm(); + store.open("shell-1", COLS, ROWS); + for (const chunk of chunks) { + store.feed("shell-1", new TextEncoder().encode(chunk)); + } + return store; +} + +/** Writes a snapshot into a fresh emulator and snapshots that. A faithful + * serialization is a fixed point: replaying it must reproduce itself. */ +async function replay(data: string): Promise { + const store = new ShellReplayStore(); + await store.warm(); + store.open("replayed", COLS, ROWS); + store.feed("replayed", new TextEncoder().encode(data)); + const snapshot = store.snapshot("replayed", "screen"); + store.close("replayed"); + return snapshot?.data ?? ""; +} + +describe("shell replay", () => { + test("repaints plain output", async () => { + const store = await storeWith("hello\r\nworld\r\n"); + const snapshot = store.snapshot("shell-1", "screen"); + expect(snapshot).not.toBeNull(); + expect(snapshot?.data).toContain("hello"); + expect(snapshot?.data).toContain("world"); + expect(snapshot?.seq).toBe(1); + expect(snapshot).toMatchObject({ + shellId: "shell-1", + mode: "screen", + cols: COLS, + rows: ROWS, + }); + store.close("shell-1"); + }); + + test("round-trips through a fresh emulator", async () => { + const store = await storeWith( + "plain\r\n", + "\x1b[31mred\x1b[0m \x1b[1;4mbold-underline\x1b[0m\r\n", + "\x1b[48;2;10;20;30mrgb-bg\x1b[0m\r\n", + "trailing spaces \r\n", + "unicode: héllo ✓\r\n", + ); + const snapshot = store.snapshot("shell-1", "screen"); + expect(await replay(snapshot?.data ?? "")).toBe(snapshot?.data); + store.close("shell-1"); + }); + + test("preserves alternate screen and cursor position", async () => { + const store = await storeWith("\x1b[?1049h", "\x1b[3;5Hin-alt-screen"); + const snapshot = store.snapshot("shell-1", "screen"); + expect(snapshot?.data).toContain("\x1b[?1049h"); + expect(snapshot?.data).toContain("in-alt-screen"); + // Cursor lands just past the text it wrote. + expect(snapshot?.data).toContain("\x1b[3;18H"); + expect(await replay(snapshot?.data ?? "")).toBe(snapshot?.data); + store.close("shell-1"); + }); + + test("restores DEC modes a client would otherwise lose", async () => { + const store = await storeWith("\x1b[?1h\x1b[?2004h\x1b[?1006h\x1b[?25l"); + const data = store.snapshot("shell-1", "screen")?.data ?? ""; + expect(data).toContain("\x1b[?1h"); + expect(data).toContain("\x1b[?2004h"); + expect(data).toContain("\x1b[?1006h"); + expect(data).toContain("\x1b[?25l"); + // Modes the guest never enabled are explicitly reset, not left to chance. + expect(data).toContain("\x1b[?1003l"); + store.close("shell-1"); + }); + + test("scrollback mode carries lines above the viewport", async () => { + const lines = Array.from({ length: ROWS + 5 }, (_, i) => `line-${i}\r\n`); + const store = await storeWith(...lines); + const screen = store.snapshot("shell-1", "screen")?.data ?? ""; + const scrollback = store.snapshot("shell-1", "scrollback")?.data ?? ""; + expect(screen).not.toContain("line-0"); + expect(scrollback).toContain("line-0"); + expect(scrollback).toContain(`line-${ROWS + 4}`); + store.close("shell-1"); + }); + + test("`none` carries a sequence but no repaint", async () => { + const store = await storeWith("output\r\n", "more\r\n"); + expect(store.snapshot("shell-1", "none")).toMatchObject({ + mode: "none", + data: "", + seq: 2, + }); + store.close("shell-1"); + }); + + test("sequence advances per chunk and identifies unknown shells", async () => { + const store = await storeWith("a", "b", "c"); + expect(store.snapshot("shell-1", "none")?.seq).toBe(3); + expect(store.feed("unknown", new Uint8Array([1]))).toBe(0); + expect(store.snapshot("unknown", "screen")).toBeNull(); + store.close("shell-1"); + expect(store.snapshot("shell-1", "screen")).toBeNull(); + }); + + test("resize is reflected in later snapshots", async () => { + const store = await storeWith("resize me\r\n"); + store.resize("shell-1", 100, 30); + expect(store.snapshot("shell-1", "screen")).toMatchObject({ + cols: 100, + rows: 30, + }); + store.close("shell-1"); + }); + + test("open is bounded and names the limit", async () => { + const store = new ShellReplayStore(); + await store.warm(); + for (let i = 0; i < 32; i++) store.open(`shell-${i}`, COLS, ROWS); + expect(() => store.open("shell-32", COLS, ROWS)).toThrow( + /replay limit reached \(32 emulators\).*MAX_REPLAY_SHELLS/s, + ); + for (let i = 0; i < 32; i++) store.close(`shell-${i}`); + }); + + test("a reopened shell never inherits a closed shell's screen", async () => { + const store = new ShellReplayStore(); + await store.warm(); + store.open("first", COLS, ROWS); + store.feed( + "first", + new TextEncoder().encode("secret-from-first-shell\r\n"), + ); + store.close("first"); + + store.open("second", COLS, ROWS); + const fresh = store.snapshot("second", "screen")?.data ?? ""; + expect(fresh).not.toContain("secret-from-first-shell"); + store.feed("second", new TextEncoder().encode("only-this\r\n")); + const after = store.snapshot("second", "screen")?.data ?? ""; + expect(after).toContain("only-this"); + expect(after).not.toContain("secret-from-first-shell"); + store.close("second"); + }); + + test("open before warm fails loudly rather than silently skipping replay", () => { + expect(() => new ShellReplayStore().open("shell-1", COLS, ROWS)).toThrow( + /before warm\(\) resolved/, + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74afb72b75..9c7413b279 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2770,6 +2770,9 @@ importers: '@rivetkit/react': specifier: catalog:rivetkit version: 2.3.9(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(pyodide@0.28.3(bufferutil@4.1.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sql.js@1.14.1)(ws@8.21.0(bufferutil@4.1.0)) + ghostty-web: + specifier: ^0.4.0 + version: 0.4.0 rivetkit: specifier: catalog:rivetkit version: 2.3.9(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(pyodide@0.28.3(bufferutil@4.1.0))(sql.js@1.14.1)(ws@8.21.0(bufferutil@4.1.0)) @@ -11311,6 +11314,9 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} + ghostty-web@0.4.0: + resolution: {integrity: sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg==} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -23444,6 +23450,8 @@ snapshots: transitivePeerDependencies: - supports-color + ghostty-web@0.4.0: {} + github-from-package@0.0.0: {} github-slugger@2.0.0: {} From 2b75cce22e6b79c85895494ed2bbf276a9937aa8 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:04:18 +0000 Subject: [PATCH 4/4] fix(agentos): remove fetch override and export ShellSnapshot type Remove the globalThis.fetch override (fetch-compat.ts) that buffered ReadableStream request bodies for HTTP/1.1 compatibility. Export ShellSnapshot and ShellReplayMode from the package index so downstream consumers (agentos-apps) can name them in generated declarations. --- packages/agentos/src/index.ts | 2 + .../src/inspector-tabs/lib/fetch-compat.ts | 64 ------------------- packages/agentos/src/inspector-tabs/main.tsx | 4 -- 3 files changed, 2 insertions(+), 68 deletions(-) delete mode 100644 packages/agentos/src/inspector-tabs/lib/fetch-compat.ts diff --git a/packages/agentos/src/index.ts b/packages/agentos/src/index.ts index 846f760012..93c341ad11 100644 --- a/packages/agentos/src/index.ts +++ b/packages/agentos/src/index.ts @@ -55,6 +55,8 @@ export type { ShellDataPayload, ShellExitPayload, ShellInfo, + ShellReplayMode, + ShellSnapshot, VmBootedPayload, VmShutdownPayload, } from "./types.js"; diff --git a/packages/agentos/src/inspector-tabs/lib/fetch-compat.ts b/packages/agentos/src/inspector-tabs/lib/fetch-compat.ts deleted file mode 100644 index 18d48828b2..0000000000 --- a/packages/agentos/src/inspector-tabs/lib/fetch-compat.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Chrome treats a request with a `ReadableStream` body as a streaming upload, -// which it only permits over HTTP/2 — a cleartext `http://` engine is -// HTTP/1.1, so the request dies as ERR_ALPN_NEGOTIATION_FAILED before it is -// sent and the server never logs anything. rivetkit's browser client routes -// every action through a `Request` whose `.body` is a stream, so all inspector -// actions fail against a plain-http engine. Buffer such bodies back into a -// normal upload; over HTTPS this is a no-op in behavior. -// -// Action payloads are small (arguments only — file contents move as base64 in -// the JSON body), so buffering is bounded by the caller's own argument size. - -const STREAM_BODY_LIMIT_BYTES = 64 * 1024 * 1024; - -async function bufferStream( - stream: ReadableStream, -): Promise { - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - total += value.length; - if (total > STREAM_BODY_LIMIT_BYTES) { - throw new Error( - `inspector request body exceeds ${STREAM_BODY_LIMIT_BYTES} bytes; raise STREAM_BODY_LIMIT_BYTES in lib/fetch-compat.ts`, - ); - } - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - const out = new Uint8Array(new ArrayBuffer(total)); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.length; - } - return out.buffer; -} - -let installed = false; - -/** Idempotent; safe to call before any client is constructed. */ -export function installStreamBodyFetchCompat(): void { - if (installed || typeof globalThis.fetch !== "function") return; - installed = true; - const original = globalThis.fetch.bind(globalThis); - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const body = init?.body; - if (!(body instanceof ReadableStream)) return original(input, init); - const buffered = await bufferStream(body); - // `duplex` only exists to permit a stream body; carrying it over would - // re-trigger the same negotiation. - const { duplex: _duplex, ...rest } = init as RequestInit & { - duplex?: string; - }; - return original(input, { ...rest, body: buffered }); - }; -} diff --git a/packages/agentos/src/inspector-tabs/main.tsx b/packages/agentos/src/inspector-tabs/main.tsx index e1d5dd35f7..1a2063a5c2 100644 --- a/packages/agentos/src/inspector-tabs/main.tsx +++ b/packages/agentos/src/inspector-tabs/main.tsx @@ -2,7 +2,6 @@ import { dehydrate, hydrate, QueryClient, QueryClientProvider } from "@tanstack/ import { lazy, type ComponentType, StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { isInspectorActionError, tabIdFromUrl } from "./lib/actor-client"; -import { installStreamBodyFetchCompat } from "./lib/fetch-compat"; import { RivetProvider } from "./lib/rivet"; import { PermissionPrompts } from "./permission-prompts"; import { TabBoundary } from "./tab-boundary"; @@ -10,9 +9,6 @@ import React from "react"; import "./styles.css"; -// Must run before any rivetkit client is constructed. -installStreamBodyFetchCompat(); - // Tab registry: id → lazy component. Add a tab here + register the same id in // actor.ts `inspectorTabs` pointing `source` at this shared asset dir. const TABS: Record Promise<{ default: ComponentType<{ actorId: string }> }>> = {