diff --git a/packages/agentos/.gitignore b/packages/agentos/.gitignore index c19a080064..4ef27986d5 100644 --- a/packages/agentos/.gitignore +++ b/packages/agentos/.gitignore @@ -1,5 +1,6 @@ -# Built inspector-tabs app bundle (generated by `vite build`) -assets/ +# Built inspector-tabs app bundle (generated by `vite build`). Anchored so +# it does not swallow src/inspector-tabs/assets/ (tracked SVG sources). +/assets/ # Scheduled task lock .claude/scheduled_tasks.lock diff --git a/packages/agentos/src/actor.ts b/packages/agentos/src/actor.ts index c35d75d288..1b63bc550e 100644 --- a/packages/agentos/src/actor.ts +++ b/packages/agentos/src/actor.ts @@ -1,4 +1,6 @@ import crypto from "node:crypto"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { AgentOs, type AgentExitEvent, @@ -25,6 +27,9 @@ import type { AgentOsEvents, ProcessExitPayload, ProcessOutputPayload, + RuntimeAgentExit, + RuntimeHealth, + RuntimeLimitWarning, SerializableCronJobOptions, ShellDataPayload, ShellExitPayload, @@ -107,6 +112,34 @@ interface RuntimeState { const runtimes = new Map(); +// Bounded post-mortem buffers for the observe-only `health` action. Kept in +// their own map — not on RuntimeState — because `disposeVm` drops the runtime +// entry on sleep while these must stay readable while `booted` is false. They +// are cleared only when the actor is destroyed. +const HEALTH_WARNINGS_CAP = 50; +const HEALTH_AGENT_EXITS_CAP = 50; + +interface HealthBuffers { + warnings: RuntimeLimitWarning[]; + agentExits: RuntimeAgentExit[]; +} + +const healthBuffers = new Map(); + +function healthBuffersFor(actorId: string): HealthBuffers { + let buffers = healthBuffers.get(actorId); + if (!buffers) { + buffers = { warnings: [], agentExits: [] }; + healthBuffers.set(actorId, buffers); + } + return buffers; +} + +function pushCapped(buffer: T[], item: T, cap: number): void { + buffer.push(item); + while (buffer.length > cap) buffer.shift(); +} + function runtimeFor(c: AnyContext): RuntimeState { let runtime = runtimes.get(c.actorId); if (!runtime) { @@ -150,6 +183,18 @@ async function ensureVm( msg: "agent-os agent adapter exited unexpectedly", ...event, }); + pushCapped( + healthBuffersFor(c.actorId).agentExits, + { + ts: Date.now(), + sessionId: event.sessionId, + agentType: event.agentType, + exitCode: event.exitCode, + restart: event.restart, + restartCount: event.restartCount, + }, + HEALTH_AGENT_EXITS_CAP, + ); c.broadcast("agentExit", event); try { options?.onAgentExit?.(event); @@ -161,6 +206,26 @@ async function ensureVm( }); } }, + onLimitWarning: (warning) => { + c.log.warn({ + msg: "agent-os runtime limit is near capacity", + ...warning, + }); + pushCapped( + healthBuffersFor(c.actorId).warnings, + { ts: Date.now(), ...warning }, + HEALTH_WARNINGS_CAP, + ); + try { + options?.onLimitWarning?.(warning); + } catch (error) { + c.log.error({ + msg: "agent-os onLimitWarning hook failed", + limit: warning.limit, + error, + }); + } + }, rootFilesystem: { type: "native", plugin: { @@ -206,6 +271,9 @@ async function ensureVm( } async function disposeVm(c: AnyContext, reason: "sleep" | "destroy" | "error") { + // Post-mortem health buffers survive sleep and error by design; only a + // destroyed actor drops them. + if (reason === "destroy") healthBuffers.delete(c.actorId); const runtime = runtimes.get(c.actorId); if (!runtime) return; const vm = runtime.vm; @@ -638,6 +706,41 @@ export function createAgentOsActions( ) => (await ensureVm(c, options)).cancelCronJob(...args), listAgents: async (c: AnyContext) => (await ensureVm(c, options)).listAgents(), + // Observe-only: reports VM liveness and the post-mortem buffers without + // ever booting the VM (deliberately no ensureVm). + health: async (c: AnyContext): Promise => { + const runtime = runtimes.get(c.actorId); + const buffers = healthBuffersFor(c.actorId); + 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, + }; + } + } + return { + booted, + sessions, + sidecar, + warnings: [...buffers.warnings], + agentExits: [...buffers.agentExits], + stderrTail: [], + }; + }, openSession: async (c: AnyContext, input: OpenSessionInput) => { const vm = await ensureVm(c, options); await vm.openSession(input); @@ -1012,6 +1115,50 @@ function assertNoReservedKeys( } } +// Absolute path to the built inspector-tabs app (the shared Vite bundle). All +// custom tabs share this one `source` dir; the app routes on the +// `/inspector/custom-tabs//` URL segment. Resolves from both `src/` (tsx +// dev) and the published `dist/`, since `assets/` sits at the package root in +// both layouts. +const INSPECTOR_TABS_ASSET_DIR = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "assets", + "inspector-tabs-app", +); + +// Custom inspector tabs shipped by agent-os. Ids MUST match the `TABS` +// registry in `src/inspector-tabs/main.tsx`. The built-in rivetkit tabs are +// hidden so the dashboard shows only the agent-os tabs. +const AGENTOS_INSPECTOR_CONFIG = { + tabs: [ + { + id: "transcript", + label: "Transcript", + source: INSPECTOR_TABS_ASSET_DIR, + icon: "comments", + }, + { + id: "filesystem", + label: "Filesystem", + source: INSPECTOR_TABS_ASSET_DIR, + icon: "folder-tree", + }, + { + id: "system", + label: "System", + source: INSPECTOR_TABS_ASSET_DIR, + icon: "layer-group", + }, + // Processes/software/mounts live as sections inside the System tab. + // `metadata` is dashboard-owned and not hideable (the schema only + // accepts the six built-in ids below), so it stays. + ...["workflow", "database", "state", "queue", "connections", "console"].map( + (id) => ({ id, hidden: true as const }), + ), + ], +}; + export function createAgentOS< TState = undefined, TConnParams = undefined, @@ -1108,6 +1255,13 @@ export function createAgentOS< sleepGracePeriod: DEFAULT_SLEEP_GRACE_PERIOD_MS, ...actorConfig.options, }, + // Register the custom agent-os inspector tabs (and hide the built-in + // rivetkit tabs) so the dashboard renders the agent-os UI. Without + // this the shipped tab assets are never surfaced. A caller-supplied + // inspector config wins. + inspector: + (actorConfig as { inspector?: unknown }).inspector ?? + AGENTOS_INSPECTOR_CONFIG, db: db({ onMigrate: migrateAgentOsActorTables }), events: { ...(actorConfig.events ?? {}), ...builtInEvents }, actions: { ...(actorConfig.actions ?? {}), ...actions }, diff --git a/packages/agentos/src/index.ts b/packages/agentos/src/index.ts index 73d17afeec..8d982fe7fc 100644 --- a/packages/agentos/src/index.ts +++ b/packages/agentos/src/index.ts @@ -53,6 +53,11 @@ export type { AgentOsEvents, ProcessExitPayload, ProcessOutputPayload, + RuntimeAgentExit, + RuntimeHealth, + RuntimeLimitWarning, + RuntimeSidecarInfo, + RuntimeStderrLine, SerializableCronAction, SerializableCronEvent, SerializableCronJobInfo, diff --git a/packages/agentos/src/inspector-tabs/assets/agentos-hero-logo.svg b/packages/agentos/src/inspector-tabs/assets/agentos-hero-logo.svg new file mode 100644 index 0000000000..e3443834cc --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/agentos-hero-logo.svg @@ -0,0 +1,74 @@ + + + + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/browserbase.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/browserbase.svg new file mode 100644 index 0000000000..e10699d74e --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/browserbase.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/claude-code.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/claude-code.svg new file mode 100644 index 0000000000..c2c148c2d8 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/claude-code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/codex.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/codex.svg new file mode 100644 index 0000000000..6922a74eae --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/codex.svg @@ -0,0 +1 @@ +OpenAI diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/coreutils.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/coreutils.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/coreutils.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/curl.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/curl.svg new file mode 100644 index 0000000000..5788acc2d8 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/curl.svg @@ -0,0 +1 @@ +curl \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/diffutils.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/diffutils.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/diffutils.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/duckdb.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/duckdb.svg new file mode 100644 index 0000000000..ac31e6f96b --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/duckdb.svg @@ -0,0 +1 @@ +DuckDB \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/findutils.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/findutils.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/findutils.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/gawk.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/gawk.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/gawk.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/git.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/git.svg new file mode 100644 index 0000000000..52ced6bb97 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/git.svg @@ -0,0 +1 @@ +Git \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/grep.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/grep.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/grep.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/gzip.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/gzip.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/gzip.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/jq.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/jq.svg new file mode 100644 index 0000000000..1f9e2ddf6d --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/jq.svg @@ -0,0 +1 @@ + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/nodejs.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/nodejs.svg new file mode 100644 index 0000000000..3cc5893e0c --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/nodejs.svg @@ -0,0 +1 @@ +Node.js \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/opencode.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/opencode.svg new file mode 100644 index 0000000000..6d12bab7e0 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/opencode.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/pi.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/pi.svg new file mode 100644 index 0000000000..bdc0fddce9 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/pi.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/python.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/python.svg new file mode 100644 index 0000000000..e1b14ec64b --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/python.svg @@ -0,0 +1 @@ +Python \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/sed.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/sed.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/sed.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/sqlite3.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/sqlite3.svg new file mode 100644 index 0000000000..3bfccb838a --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/sqlite3.svg @@ -0,0 +1 @@ +SQLite \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/super-memory.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/super-memory.svg new file mode 100644 index 0000000000..3120c43499 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/super-memory.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/tar.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/tar.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/tar.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/vim.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/vim.svg new file mode 100644 index 0000000000..63d15125b0 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/vim.svg @@ -0,0 +1 @@ +Vim \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/wget.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/wget.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/wget.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/common.tsx b/packages/agentos/src/inspector-tabs/common.tsx index 0cd43c4013..daa4fb75da 100644 --- a/packages/agentos/src/inspector-tabs/common.tsx +++ b/packages/agentos/src/inspector-tabs/common.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from "react"; +import agentOsHeroLogo from "./assets/agentos-hero-logo.svg"; +import { type ActionErrorLayer, isInspectorActionError } from "./lib/actor-client"; import { cn } from "./lib/cn"; import React, { useState } from "react"; @@ -11,6 +13,52 @@ export function AgentOsEmpty({ children }: { children: ReactNode }) { ); } +const LAYER_LABEL: Record = { + gateway: "Gateway unreachable", + auth: "Not authorized", + contract: "Not supported by this runtime", + runtime: "Runtime error", + timeout: "Timed out", +}; + +/** "This runtime doesn't expose X" empty state for contract-layer failures — + * the graceful-degradation path for actors built against other runtimes. */ +export function UnsupportedAction({ action, className }: { action?: string; className?: string }) { + return ( +
+ Not supported by this runtime + {action ? ( + + the actor does not expose {action} + + ) : null} +
+ ); +} + +/** Compact inline error note: layer label + message + hint. Used by inline + * (non-suspense) error paths; the suspense path renders via TabBoundary. */ +export function ActionErrorNote({ error, className }: { error: unknown; className?: string }) { + if (isInspectorActionError(error) && error.layer === "contract") { + return ; + } + const layer = isInspectorActionError(error) ? error.layer : undefined; + const message = error instanceof Error ? error.message : String(error); + const hint = isInspectorActionError(error) ? error.hint : undefined; + return ( +
+
+ + + {layer ? LAYER_LABEL[layer] : "Error"} + +
+
{message}
+ {hint ?
{hint}
: null} +
+ ); +} + export type DotColor = "green" | "amber" | "red" | "muted"; const DOT_CLASS: Record = { green: "text-green-500", @@ -67,7 +115,7 @@ function CopyIcon({ className }: { className?: string }) { ); } -function CheckIcon({ className }: { className?: string }) { +export function CheckIcon({ className }: { className?: string }) { return ( + ); +} + +/** Small bordered icon button for tab chrome (new/refresh/upload/…). The + * accessible name comes from `title`, which doubles as the tooltip. */ +export function IconButton({ + title, + onClick, + disabled, + destructive, + className, + children, +}: { + title: string; + onClick: () => void; + disabled?: boolean; + destructive?: boolean; + className?: string; + children: ReactNode; +}) { + return ( + + ); +} + +const iconProps = { + viewBox: "0 0 16 16", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.5, + strokeLinecap: "round", + strokeLinejoin: "round", + "aria-hidden": true, +} as const; + +export function PlusIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function SearchIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function ArrowLeftIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function RefreshIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function FolderPlusIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function UploadIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function DownloadIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function PencilIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function TrashIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + /** Inline folder/file glyphs for the filesystem tree. */ export function FileGlyph({ dir, className }: { dir: boolean; className?: string }) { return ( diff --git a/packages/agentos/src/inspector-tabs/lib/actor-client.ts b/packages/agentos/src/inspector-tabs/lib/actor-client.ts index 99962cc799..c4de484584 100644 --- a/packages/agentos/src/inspector-tabs/lib/actor-client.ts +++ b/packages/agentos/src/inspector-tabs/lib/actor-client.ts @@ -32,10 +32,89 @@ export function tabIdFromUrl(): string | undefined { return idx >= 0 ? parts[idx + 1] : undefined; } -export interface ActionError { - group?: string; - code?: string; - message?: string; +/** Which hop of the dashboard→gateway→actor→VM chain an action failure came + * from. Drives per-layer rendering (tab-boundary.tsx / common.tsx) and the + * retry policy (main.tsx): contract/auth failures never retry. */ +export type ActionErrorLayer = "gateway" | "auth" | "contract" | "runtime" | "timeout"; + +export class InspectorActionError extends Error { + readonly layer: ActionErrorLayer; + readonly action: string; + readonly hint: string; + constructor(layer: ActionErrorLayer, action: string, message: string, hint: string) { + super(message); + this.name = "InspectorActionError"; + this.layer = layer; + this.action = action; + this.hint = hint; + } +} + +export function isInspectorActionError(error: unknown): error is InspectorActionError { + return ( + error instanceof InspectorActionError || + (typeof error === "object" && + error !== null && + (error as { name?: string }).name === "InspectorActionError") + ); +} + +/** Map a raw transport/actor error onto the failing layer. Order matters: + * abort before contract before runtime (messages overlap on "error"). */ +function classifyActionError(action: string, error: unknown): InspectorActionError { + const message = error instanceof Error ? error.message : String(error); + const rivet = (error ?? {}) as { + group?: string; + code?: string; + statusCode?: number; + name?: string; + }; + if (rivet.name === "AbortError" || /\baborted\b/i.test(message)) { + return new InspectorActionError( + "timeout", + action, + `${action} timed out`, + "The VM may still be booting or the call is hung — retry in a few seconds.", + ); + } + if (/was not found/i.test(message) && /action/i.test(message)) { + return new InspectorActionError( + "contract", + action, + `This runtime does not expose the \`${action}\` action.`, + "The actor was built against a different agentOS runtime version; this panel is unavailable.", + ); + } + if ( + rivet.statusCode === 401 || + rivet.statusCode === 403 || + rivet.code === "unauthorized" || + /bearer token|x-rivet-token|unauthorized|forbidden/i.test(message) + ) { + return new InspectorActionError( + "auth", + action, + message, + "The inspector's token was rejected — reload the dashboard to refresh credentials.", + ); + } + if (rivet.code === "internal_error" || /internal error/i.test(message)) { + return new InspectorActionError( + "runtime", + action, + `${action} failed inside the actor runtime.`, + "The engine masks the underlying error. Common causes: the agent adapter timed out while booting (retry — a warm VM answers faster) or the sidecar exited. The server log has the raw error.", + ); + } + if (error instanceof TypeError || /fetch failed|failed to fetch|networkerror/i.test(message)) { + return new InspectorActionError( + "gateway", + action, + `Could not reach the actor gateway (${message}).`, + "The engine/gateway is unreachable — check that the server is running.", + ); + } + return new InspectorActionError("runtime", action, message || `${action} failed`, "See server logs for the underlying error."); } function getHandle(): Any { @@ -63,6 +142,8 @@ export async function callAction( const timer = opts.timeoutMs ? setTimeout(() => ctrl.abort(), opts.timeoutMs) : undefined; try { return (await h.action({ name, args, signal: ctrl.signal })) as T; + } catch (error) { + throw classifyActionError(name, error); } finally { if (timer) clearTimeout(timer); } diff --git a/packages/agentos/src/inspector-tabs/lib/health.ts b/packages/agentos/src/inspector-tabs/lib/health.ts new file mode 100644 index 0000000000..86c22b67e3 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/lib/health.ts @@ -0,0 +1,13 @@ +// Feature detection for the observe-only runtime-health actions. The queries +// themselves (healthQueryOptions / sessionsQueryOptions / cancelPrompt) +// live in lib/source.ts now that health / listSessions / +// cancelPrompt are real contract actions; this module keeps only the +// contract-layer detection callers use to hide health UI when a vendored tab +// bundle runs against an OLDER runtime that lacks the actions (those reject +// with a contract-layer InspectorActionError; the status strip hides itself +// and the composer disables its Stop button). +import { isInspectorActionError } from "./actor-client"; + +export function isMissingHealthAction(error: unknown): boolean { + return isInspectorActionError(error) && error.layer === "contract"; +} diff --git a/packages/agentos/src/inspector-tabs/lib/source.ts b/packages/agentos/src/inspector-tabs/lib/source.ts index 45620692fa..2afa6dfb61 100644 --- a/packages/agentos/src/inspector-tabs/lib/source.ts +++ b/packages/agentos/src/inspector-tabs/lib/source.ts @@ -1,29 +1,35 @@ -// Real data source for the inspector tabs — the deliberate replacement for the -// mockup's `createLiveAgentOsSource`. Each query calls a REAL agentOS action via -// the gateway (actor-client) and transforms the result into the display type the -// ported component expects. Action names/shapes are the actual ones, not the -// mockup's aspirational `agentOs*` names. -import { queryOptions } from "@tanstack/react-query"; -import type { SessionStreamEntry } from "@rivet-dev/agentos-core"; -import { callAction } from "./actor-client"; +// Real data source for the inspector tabs. Each query calls a REAL agentOS +// 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 { callAction, isInspectorActionError } from "./actor-client"; import type { FileContent, FsEntry, MountInfo, + PendingPermissionDisplay, ProcessInfo, + ProcessTreeNode, ReaddirEntry, + RuntimeHealth, SessionInfo, + SessionStreamEntry, + SignedPreviewUrl, SoftwareBundle, SoftwareInfo, TranscriptEvent, VirtualStat, } 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 { @@ -40,6 +46,7 @@ function softwareInfoToBundle(info: SoftwareInfo): SoftwareBundle { : "user"; return { name, + slug: (pkg.split("/").filter(Boolean).pop() ?? pkg).toLowerCase(), version: "—", source, binaries: info.commands ?? [], @@ -51,15 +58,11 @@ function joinPath(dir: string, name: string): string { return dir === "/" ? `/${name}` : `${dir}/${name}`; } -function decodeActionBytes(output: unknown): Uint8Array { +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); @@ -79,6 +82,9 @@ function decodeActionBytes(output: unknown): Uint8Array { return new Uint8Array(); } +/** Preview limit for the file viewer; larger files load only on request. */ +const MAX_PREVIEW_BYTES = 4 * 1024 * 1024; + function bytesToDisplay(bytes: Uint8Array): string | null { // Heuristic binary check: NUL byte in the first 8 KiB. const probe = bytes.subarray(0, 8192); @@ -86,51 +92,154 @@ function bytesToDisplay(bytes: Uint8Array): string | null { return new TextDecoder("utf-8", { fatal: false }).decode(bytes); } -// ── Transcript mapper (defensive: unknown ACP updates → "raw") ───────── -// Map the flat public session-event union to a display event. -export function mapSessionEvent(event: SessionStreamEntry): TranscriptEvent { - const text = - event.type === "user_message_chunk" || - event.type === "agent_message_chunk" || - event.type === "agent_thought_chunk" - ? event.content.type === "text" - ? event.content.text - : "" - : ""; - switch (event.type) { +// ── Transcript mapper (defensive: unknown entries → "raw") ──────────── +/** Ordering key for a stream entry: the durable `sequence`, or + * `afterSequence + 0.5` for ephemeral streaming deltas so they sort after the + * durable entry they follow. */ +export function entrySeq(entry: SessionStreamEntry): number { + return entry.durability === "durable" + ? entry.sequence + : entry.afterSequence + 0.5; +} + +// Map one flat public session-event entry (durable history row or live +// broadcast) to a display event. Shared by the `readHistory` backfill and the +// live `sessionEvent` stream — both carry the same `SessionStreamEntry` union. +export function mapSessionEvent(entry: SessionStreamEntry): TranscriptEvent { + const seq = entrySeq(entry); + const e = entry as SessionStreamEntry & Record; + switch (entry.type) { case "user_message_chunk": - return { kind: "user", text }; case "agent_message_chunk": - return { kind: "assistant", text }; - case "agent_thought_chunk": - return { kind: "thinking", text }; + 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 : ""; + return { + kind: + entry.type === "user_message_chunk" + ? "user" + : entry.type === "agent_message_chunk" + ? "assistant" + : "thinking", + seq, + text, + }; + } case "tool_call": - case "tool_call_update": + case "tool_call_update": { + // ACP tool content: text blocks become output; diff entries are + // summarized by path (full diff rendering stays behind "raw"). + const outputParts: string[] = []; + if (Array.isArray(e.content)) { + 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; + if (inner?.type === "text" && typeof inner.text === "string") { + outputParts.push(inner.text); + } + } else if (c.type === "diff" && typeof c.path === "string") { + outputParts.push(`[edit] ${c.path}`); + } + } + } + const locations = Array.isArray(e.locations) + ? (e.locations as { path?: string }[]) + .map((l) => l?.path) + .filter((p): p is string => typeof p === "string") + : undefined; return { kind: "tool", - tool: event.title ?? event.toolCallId ?? "tool", - status: event.status ?? undefined, + seq, + toolCallId: typeof e.toolCallId === "string" ? e.toolCallId : undefined, + tool: (e.title as string) ?? (e.toolCallId as string) ?? "tool", + status: e.status as string | undefined, + input: e.rawInput, + output: outputParts.length > 0 ? outputParts.join("\n") : undefined, + locations: locations && locations.length > 0 ? locations : undefined, + }; + } + case "plan": { + const entries = Array.isArray(e.entries) + ? (e.entries as Record[]).map((p) => ({ + content: + typeof p?.content === "string" ? p.content : JSON.stringify(p?.content ?? ""), + status: typeof p?.status === "string" ? p.status : undefined, + })) + : []; + return { kind: "plan", seq, entries }; + } + case "current_mode_update": + return { + kind: "notice", + seq, + text: `Mode changed to ${String(e.currentModeId ?? "unknown")}`, + }; + case "available_commands_update": { + const count = Array.isArray(e.availableCommands) ? e.availableCommands.length : 0; + return { + kind: "notice", + seq, + text: `${count} agent command${count === 1 ? "" : "s"} available`, + }; + } + case "permission_request": { + const toolCall = e.toolCall as { title?: string } | undefined; + return { + kind: "permission", + seq, + text: `Permission requested${toolCall?.title ? `: ${toolCall.title}` : ""}`, + }; + } + case "permission_response": + return { + kind: "notice", + seq, + text: + e.status === "accepted" + ? "Permission request answered" + : `Permission request closed (${String(e.reason ?? "not pending")})`, }; default: - return { kind: "raw", label: event.type, json: event }; + return { kind: "raw", seq, label: entry.type ?? "event", json: entry }; } } +/** 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[] { + return sessions.flatMap((session) => + session.state.status === "waiting" + ? session.state.requests.map((request) => ({ ...request, sessionId: session.sessionId })) + : [], + ); +} + // ── Query options ───────────────────────────────────────────────────── export const agentOsSource = { softwareQueryOptions: (actorId: string) => queryOptions({ queryKey: k(actorId, "software"), queryFn: async () => - (await callAction("listSoftware", [])).map( - softwareInfoToBundle, - ), + (await callAction("listSoftware", [])).map(softwareInfoToBundle), }), processesQueryOptions: (actorId: string) => queryOptions({ queryKey: k(actorId, "processes"), queryFn: () => callAction("listProcesses", []), + // Keep the table current while the tab is open; processExit broadcasts + // also invalidate it immediately. + refetchInterval: 5_000, + }), + + // Full kernel process forest (every process, not just SDK-spawned). + processTreeQueryOptions: (actorId: string) => + queryOptions({ + queryKey: k(actorId, "process-tree"), + queryFn: () => callAction("processTree", [], { timeoutMs: 10_000 }), + refetchInterval: 5_000, }), // Lazy per-directory listing via ONE `readdirEntries` call: the sidecar @@ -145,13 +254,9 @@ 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 !== "..") @@ -164,30 +269,68 @@ export const agentOsSource = { name: e.name, path: p, dir: e.isDirectory, + symlink: e.isSymbolicLink, }; }); 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) => + fileContentQueryOptions: (actorId: string, path: string | null, force = false) => queryOptions({ - queryKey: k(actorId, "file", path ?? ""), + queryKey: k(actorId, "file", path ?? "", force ? "force" : "guarded"), enabled: !!path, + // Selecting another file keeps the previous one on screen until the + // new content lands, instead of flashing the whole viewer. + placeholderData: keepPreviousData, queryFn: async (): Promise => { const p = path as string; - const [bytes, stat] = await Promise.all([ - callAction("readFile", [p]).then(decodeActionBytes), - callAction("stat", [p]), - ]); + // Stat first: reading a huge file drags megabytes through the + // gateway just to preview it. Past the limit, skip the read until + // the viewer's explicit "Load anyway". + const stat = await callAction("stat", [p]); + // Device/fifo/socket nodes (/dev/stdout, …) are streams: reading + // them fails or hangs, so never try. Regular files and symlinks + // (followed by readFile) proceed. + const fileType = (stat.mode ?? 0) & 0o170000; + if ( + fileType === 0o020000 || // character device + fileType === 0o060000 || // block device + fileType === 0o010000 || // fifo + fileType === 0o140000 // socket + ) { + return { + path: p, + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + text: null, + bytes: null, + oversize: false, + special: true, + }; + } + if (!force && stat.size > MAX_PREVIEW_BYTES) { + return { + path: p, + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + text: null, + bytes: null, + oversize: true, + }; + } + const bytes = decodeActionBytes( + await callAction("readFile", [p], { timeoutMs: 30_000 }), + ); return { path: p, sizeBytes: stat.size, mtimeMs: stat.mtimeMs, text: bytesToDisplay(bytes), + bytes, + oversize: false, }; }, }), @@ -198,11 +341,133 @@ export const agentOsSource = { queryFn: () => callAction("listMounts", []), }), + // Durable session records with liveness (`state.status`) built in — one + // action covers what previously took a persisted list + a live list. sessionsQueryOptions: (actorId: string) => queryOptions({ queryKey: k(actorId, "sessions"), - queryFn: () => callAction("listSessions", []), - // Poll so newly created and closed sessions stay current. + queryFn: async () => + (await callAction("listSessions", [])).sessions, + // Poll so newly created sessions and running/waiting status dots stay + // current. refetchInterval: 10_000, }), + + // One-off persisted backfill via durable history. Live events after this + // snapshot arrive on the `sessionEvent` broadcast (same flat entry union), + // so this does not poll. + transcriptQueryOptions: (actorId: string, sessionId: string | null) => + queryOptions({ + queryKey: k(actorId, "transcript", sessionId ?? ""), + enabled: !!sessionId, + queryFn: async () => + ( + await callAction("readHistory", [{ sessionId }]) + ).events.map(mapSessionEvent), + }), + + // ── Composer actions (transcript tab) ───────────────────────────────── + // Imperative (not queries): the composer drives the agent. Streamed output + // arrives on the existing `sessionEvent` subscription; `prompt` resolves + // when the turn completes. + sendPrompt: (sessionId: string, text: string) => + callAction("prompt", [ + { sessionId, content: [{ type: "text", text }] }, + ]), + // `openSession` resolves with no value; the caller supplies the id (or one + // is generated here) and uses it afterward. + createSession: async ( + agent: string, + options: { env?: Record }, + ): Promise => { + const sessionId = crypto.randomUUID(); + await callAction("openSession", [{ sessionId, agent, env: options.env }]); + return sessionId; + }, + + // ── Permission approvals (global banner, permission-prompts.tsx) ───── + // Answers a pending permission request with one of ITS OWN ACP options + // (render the request's `options`; don't assume a fixed once/always/reject + // set). Another viewer may answer first or the prompt may end — that comes + // back as `{status: "not_pending", reason}`, not an error. + respondPermission: (sessionId: string, requestId: string, optionId: string) => + callAction("respondPermission", [ + { sessionId, requestId, optionId }, + ]), + + // ── Process control (processes tab) ─────────────────────────────────── + killProcess: (pid: number) => callAction("killProcess", [pid]), + stopProcess: (pid: number) => callAction("stopProcess", [pid]), + + // ── Filesystem mutations (filesystem tab) ────────────────────────────── + writeFile: (path: string, content: Uint8Array | string) => + callAction("writeFile", [path, content], { timeoutMs: 30_000 }), + mkdir: (path: string) => callAction("mkdir", [path]), + moveEntry: (from: string, to: string) => callAction("move", [from, to]), + deleteFile: (path: string, options: { recursive?: boolean }) => + callAction("remove", [path, options]), + + // ── Session management (transcript tab) ──────────────────────────────── + // Close ends the live agent process; the persisted transcript stays. + 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]), }; + +// ── Runtime health (observe-only actions) ───────────────────────────── +// `health` reads VM liveness and post-mortem buffers without booting the VM. +// Feature detection stays: vendored tab bundles can run against an OLDER +// published runtime without the action, which rejects at the contract layer +// (see lib/health.ts's isMissingHealthAction) — the status badges hide +// themselves and the composer disables its Stop button. + +export const healthQueryOptions = (actorId: string) => + queryOptions({ + queryKey: k(actorId, "runtime-health"), + 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). + retry: false, + refetchOnMount: false, + }); + +/** Pending permission requests — the one-off backfill for the permission + * banner (permission-prompts.tsx), derived from the durable session records, + * so requests raised while no inspector iframe was open still render. No + * refetchInterval: after the mount fetch, updates arrive as `sessionEvent` + * entries (`permission_request` / `permission_response`). */ +export const pendingPermissionsQueryOptions = (actorId: string) => + queryOptions({ + queryKey: k(actorId, "pending-permissions"), + queryFn: async (): Promise => { + try { + const page = await callAction("listSessions", []); + return pendingPermissionsOf(page.sessions); + } catch (error) { + if (isInspectorActionError(error) && error.layer === "contract") return null; + throw error; + } + }, + retry: false, + }); + +/** Session ids currently live (loaded in the VM), derived from the same + * durable records the sidebar renders. */ +export function liveSessionIds(sessions: SessionInfo[]): Set { + return new Set(sessions.filter(sessionIsLive).map((s) => s.sessionId)); +} + +/** Best-effort prompt cancellation; resolves false when unsupported. */ +export async function cancelPrompt(sessionId: string): Promise { + try { + await callAction("cancelPrompt", [{ sessionId }]); + return true; + } catch (error) { + 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 6cb0d5282a..a72013e066 100644 --- a/packages/agentos/src/inspector-tabs/lib/types.ts +++ b/packages/agentos/src/inspector-tabs/lib/types.ts @@ -1,9 +1,31 @@ // Display + raw types for the inspector tabs. Raw types match the agentOS // action return shapes; the source adapter (source.ts) transforms raw → display. +// Session/permission shapes come straight from the core public API — the actor +// actions are thin wrappers over `AgentOs`, so core types ARE the wire types. +import type { + AgentExitEvent, + PendingPermissionRequest, + SessionInfo, +} from "@rivet-dev/agentos-core"; + +export type { + AgentExitEvent, + DurableSessionEventEntry, + PendingPermissionRequest, + SessionInfo, + SessionStreamEntry, +} from "@rivet-dev/agentos-core"; +export type { + RuntimeAgentExit, + RuntimeHealth, + RuntimeLimitWarning, +} from "../../types"; // ── Software ────────────────────────────────────────────────────────── export interface SoftwareBundle { name: string; + /** Package basename ("coreutils", "claude-code") — keys the logo lookup. */ + slug: string; version: string; source: "rivet-dev" | "user"; binaries: string[]; // command names the package ships (from SoftwareInfo.commands) @@ -27,6 +49,49 @@ export interface ProcessInfo { /** Epoch milliseconds when the process was spawned. */ startedAt: number; } +/** Raw `allProcesses`/`processTree` node fields — the full kernel process + * table (every process, not just SDK-spawned). Mirrors the Rust wire shape; + * `startTime`/`exitTime` are epoch milliseconds. */ +export interface KernelProcessInfo { + pid: number; + ppid: number; + pgid: number; + sid: number; + driver: string; + command: string; + args: string[]; + cwd: string; + status: "running" | "exited"; + exitCode: number | null; + startTime: number; + exitTime: number | null; +} +/** Raw `processTree` node: kernel info + children. */ +export interface ProcessTreeNode extends KernelProcessInfo { + children: ProcessTreeNode[]; +} +/** Live `processOutput` broadcast payload mirror. `data` arrives + * Uint8Array-shaped but encoding-dependent — normalize with + * `decodeActionBytes`. Only SDK-`spawn`ed pids have output pumps. */ +export interface ProcessOutputPayload { + pid: number; + stream: "stdout" | "stderr"; + data: unknown; +} +/** Live `processExit` broadcast payload mirror. */ +export interface ProcessExitPayload { + pid: number; + exitCode: number; +} + +/** Raw `createPreviewUrl` result. `path` is relative to the gateway + * origin serving this iframe. */ +export interface SignedPreviewUrl { + path: string; + token: string; + port: number; + expiresAt: number; +} // ── Filesystem ──────────────────────────────────────────────────────── /** Raw `readdirRecursive` entry. */ @@ -43,6 +108,8 @@ export interface FsEntry { path: string; dir: boolean; size?: number; + /** Reported lstat-style (not followed); rendered with a link marker. */ + symlink?: boolean; /** Virtual/system fs (/proc, /sys, …) — shown but not stat-ed or expanded, * because touching it wedges the VM sidecar. */ virtual?: boolean; @@ -56,6 +123,8 @@ export interface ReaddirEntry { } /** Raw `stat` shape (subset we use). */ export interface VirtualStat { + /** POSIX mode bits (file type in the top nibble). */ + mode: number; size: number; mtimeMs: number; isDirectory: boolean; @@ -65,12 +134,18 @@ export interface FileContent { path: string; sizeBytes: number; mtimeMs: number; - text: string | null; // null = binary + text: string | null; // null = binary or not loaded + /** Raw bytes for download / image preview; null when skipped (oversize). */ + bytes: Uint8Array | null; + /** True when the file exceeded the preview limit and was not read. */ + oversize: boolean; + /** True for device/fifo/socket nodes — streams with no readable contents. */ + special?: boolean; } // ── Mounts ──────────────────────────────────────────────────────────── -/** Safe `listMounts` metadata derived from the actor's declarative mounts. - * Native plugin config is intentionally omitted because it may contain secrets. */ +/** Raw `listMounts` entry — echoes the actor's declarative mount config. + * The kernel has no runtime mount table to enumerate. */ export interface MountInfo { path: string; kind: string; @@ -79,12 +154,50 @@ export interface MountInfo { } // ── Sessions / transcript ───────────────────────────────────────────── -export interface SessionInfo { +/** Mapped, displayable transcript event (defensive; unknown → "raw"). Carries + * the source `seq` for stable keys/ordering: the durable `sequence` for + * persisted entries, or `afterSequence + 0.5` for ephemeral streaming deltas + * (they sort after the durable entry they follow). Tool events keep + * `toolCallId` so the render pipeline can merge a call and its status updates + * into one card. */ +export type TranscriptEvent = { seq: number } & ( + | { kind: "user" | "assistant" | "thinking"; text: string } + | { + kind: "tool"; + tool: string; + toolCallId?: string; + status?: string; + input?: unknown; + output?: string; + locations?: string[]; + } + | { kind: "plan"; entries: { content: string; status?: string }[] } + | { kind: "notice"; text: string } + | { kind: "permission"; text: string } + | { kind: "raw"; label: string; json: unknown } + | { kind: "error"; text: string } +); + +/** One pending permission request flattened for display: the session's + * `state.requests` entry (core `PendingPermissionRequest`) plus the session it + * belongs to. Backfilled from `listSessions` (sessions with + * `state.status === "waiting"`); live updates arrive as `sessionEvent` + * entries with `type === "permission_request"` / `"permission_response"`. */ +export interface PendingPermissionDisplay extends PendingPermissionRequest { sessionId: string; - agentType: string; } -/** Mapped, displayable live transcript event (defensive; unknown → "raw"). */ -export type TranscriptEvent = - | { kind: "user" | "assistant" | "thinking"; text: string } - | { kind: "tool"; tool: string; status?: string } - | { kind: "raw"; label: string; json: unknown }; + +/** Crash-row payload: core's `AgentExitEvent`, broadcast as `agentExit`. */ +export type AgentCrashedPayload = AgentExitEvent; + +/** Session liveness derived from core `SessionInfo.state`. */ +export function sessionIsLive(session: SessionInfo): boolean { + return ( + session.state.status === "running" || session.state.status === "waiting" + ); +} + +/** Live `vmShutdown` broadcast payload mirror. */ +export interface VmShutdownPayload { + reason?: "sleep" | "destroy" | "error" | string; +} diff --git a/packages/agentos/src/inspector-tabs/main.tsx b/packages/agentos/src/inspector-tabs/main.tsx index 4a18026e8f..61c72513f5 100644 --- a/packages/agentos/src/inspector-tabs/main.tsx +++ b/packages/agentos/src/inspector-tabs/main.tsx @@ -1,8 +1,9 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { dehydrate, hydrate, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { lazy, type ComponentType, StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; -import { tabIdFromUrl } from "./lib/actor-client"; +import { isInspectorActionError, tabIdFromUrl } from "./lib/actor-client"; import { RivetProvider } from "./lib/rivet"; +import { PermissionPrompts } from "./permission-prompts"; import { TabBoundary } from "./tab-boundary"; import React from "react"; @@ -11,25 +12,47 @@ import "./styles.css"; // 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 }> }>> = { - software: () => - import("./tabs/software").then((m) => ({ default: m.SoftwareTabConnected })), - processes: () => - import("./tabs/processes").then((m) => ({ default: m.ProcessesTabConnected })), - filesystem: () => - import("./tabs/filesystem").then((m) => ({ default: m.FilesystemTabConnected })), - mounts: () => - import("./tabs/mounts").then((m) => ({ default: m.MountsTabConnected })), transcript: () => import("./tabs/transcript").then((m) => ({ default: m.TranscriptTabConnected })), + filesystem: () => + import("./tabs/filesystem").then((m) => ({ default: m.FilesystemTabConnected })), + system: () => + import("./tabs/system").then((m) => ({ default: m.SystemTabConnected })), +}; + +// Hosts vendor built copies of this bundle and pin their tab-id config at +// server start, so ids that existed in older configs must keep rendering. +// Software, Mounts, and Processes merged into System; route their ids there. +const LEGACY_TAB_ALIASES: Record = { + software: "system", + mounts: "system", + processes: "system", }; +// Theme comes from the dashboard via the iframe URL; the tokens and all +// `dark:` variants key off this class. Absent param = dark (today's default). +document.documentElement.classList.toggle( + "dark", + new URLSearchParams(window.location.search).get("theme") !== "light", +); + const queryClient = new QueryClient({ defaultOptions: { queries: { // VM-backed actions (listProcesses/readdir/readFile/stat) time out on // the FIRST call after the actor's VM hibernates and cold-wakes; - // retry so the query recovers once the VM is warm. - retry: 3, + // retry so the query recovers once the VM is warm. Contract/auth + // failures are permanent for this actor — retrying only delays the + // error UI by the full backoff, so fail those immediately. + retry: (failureCount, error) => { + if ( + isInspectorActionError(error) && + (error.layer === "contract" || error.layer === "auth") + ) { + return false; + } + return failureCount < 3; + }, retryDelay: (attempt) => Math.min(1500 * 2 ** attempt, 8000), refetchOnWindowFocus: false, staleTime: 5_000, @@ -37,6 +60,42 @@ const queryClient = new QueryClient({ }, }); +// The dashboard swaps this iframe out on every tab switch, so each switch is +// a full document boot. Carry the query cache across in sessionStorage: the +// next document renders every tab from cached data instantly (no loading +// states) and refetches in the background (5s staleTime). Excluded keys: +// `file` bodies hold Uint8Arrays (huge and not JSON-safe) and re-fetch +// quickly anyway. +const QUERY_CACHE_KEY = "agentos-inspector:query-cache"; +const QUERY_CACHE_EXCLUDED_KINDS = new Set(["file"]); +const QUERY_CACHE_MAX_BYTES = 3 * 1024 * 1024; +try { + const raw = sessionStorage.getItem(QUERY_CACHE_KEY); + if (raw) hydrate(queryClient, JSON.parse(raw)); +} catch (error) { + console.warn("agentos inspector: failed to restore query cache", error); +} +window.addEventListener("pagehide", () => { + try { + const state = dehydrate(queryClient, { + shouldDehydrateQuery: (query) => + query.state.status === "success" && + !QUERY_CACHE_EXCLUDED_KINDS.has(String(query.queryKey[2])), + }); + const raw = JSON.stringify(state); + // sessionStorage has a ~5MB origin budget; leave headroom. + if (raw.length > QUERY_CACHE_MAX_BYTES) { + console.warn( + `agentos inspector: query cache too large to persist (${raw.length} chars)`, + ); + return; + } + sessionStorage.setItem(QUERY_CACHE_KEY, raw); + } catch (error) { + console.warn("agentos inspector: failed to persist query cache", error); + } +}); + function App() { const [auth, setAuthState] = useState<{ actorId: string; authToken: string }>(); @@ -54,7 +113,8 @@ function App() { }, []); const tabId = tabIdFromUrl(); - const loader = tabId ? TABS[tabId] : undefined; + const resolvedTabId = tabId ? (LEGACY_TAB_ALIASES[tabId] ?? tabId) : undefined; + const loader = resolvedTabId ? TABS[resolvedTabId] : undefined; if (!loader) { return
Unknown inspector tab: {String(tabId)}
; @@ -66,12 +126,22 @@ function App() { const Tab = lazy(loader); // RivetProvider owns the shared rivetkit client (typed `useActor` + the // `callAction` transport). One boundary catches both the code-split chunk - // load and the tab's useSuspenseQuery data load. + // load and the tab's useSuspenseQuery data load. VM status renders as + // compact badges inside each tab's own top bar (vm-status-badges.tsx), + // not as a dedicated row here. return ( - - - +
+ {/* Permission prompts sit outside the boundary: an agent blocked on + approval must stay answerable from every tab even when the tab + body itself is broken. */} + +
+ + + +
+
); } diff --git a/packages/agentos/src/inspector-tabs/permission-prompts.tsx b/packages/agentos/src/inspector-tabs/permission-prompts.tsx new file mode 100644 index 0000000000..e4fadc64c5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/permission-prompts.tsx @@ -0,0 +1,220 @@ +// Global permission-approval banner, mounted in main.tsx above every tab (each +// tab is its own iframe, so "global" means "in every iframe's chrome"). An +// agent blocked on a permission request otherwise looks frozen: the session +// sits in durable "waiting" until someone answers. Cards come from two sources +// merged on `sessionId:requestId`: a one-off backfill on mount (durable +// "waiting" sessions via `listSessions`, so requests raised while no inspector +// iframe was open still surface) plus live `sessionEvent` entries of type +// `permission_request`. A `permission_response` entry drops the card another +// viewer already answered. +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { ChevronRight } from "./common"; +import { isInspectorActionError } from "./lib/actor-client"; +import { useAgentOsActor } from "./lib/rivet"; +import { agentOsSource, pendingPermissionsQueryOptions } from "./lib/source"; +import type { PendingPermissionDisplay, SessionStreamEntry } from "./lib/types"; +import React from "react"; + +// Bounded queue: drop the oldest card beyond this (an unbounded queue would +// just stack dead cards; resolutions arrive as `permission_response` entries). +const MAX_CARDS = 32; + +interface PermissionCard { + sessionId: string; + requestId: string; + /** The request's OWN ACP options — one button per entry (never a fixed set). */ + options: PendingPermissionDisplay["options"]; + toolCall: PendingPermissionDisplay["toolCall"]; + state: "pending" | "busy" | "resolved" | "failed"; + /** `PermissionTerminalReason` when the request resolved out from under us. */ + reason?: string; + error?: string; +} + +function cardKey(card: Pick): string { + return `${card.sessionId}:${card.requestId}`; +} + +/** Detail payload for the collapsible block: the tool call's raw input, + * hidden when absent or an empty object. */ +function toolCallDetail(toolCall: PermissionCard["toolCall"]): unknown { + const raw = toolCall?.rawInput; + if (raw == null) return undefined; + if (typeof raw === "object" && !Array.isArray(raw) && Object.keys(raw).length === 0) { + return undefined; + } + return raw; +} + +export function PermissionPrompts({ actorId }: { actorId: string }) { + const [cards, setCards] = useState([]); + + // One-off backfill of requests raised before this iframe subscribed. + // `data === null` means the runtime predates the durable-sessions contract + // (contract-layer error) — stay live-only silently. + const backfill = useQuery(pendingPermissionsQueryOptions(actorId)); + const backfillRows = backfill.data; + useEffect(() => { + if (!backfillRows || backfillRows.length === 0) return; + setCards((prev) => { + // Live cards win the dedupe: a `sessionEvent` that raced the fetch + // already carries the same request (identity is sessionId:requestId). + const have = new Set(prev.map(cardKey)); + const added = backfillRows + .filter((row) => !have.has(cardKey(row))) + .map( + (row): PermissionCard => ({ + sessionId: row.sessionId, + requestId: row.requestId, + options: row.options, + toolCall: row.toolCall, + state: "pending", + }), + ); + if (added.length === 0) return prev; + // Backfilled requests predate anything received live: keep them first. + return [...added, ...prev].slice(-MAX_CARDS); + }); + }, [backfillRows]); + + // Live stream: durable `sessionEvent` entries carry both new requests and + // their resolutions — same cast pattern as transcript.tsx (the handler + // treats payloads as unknown and narrows on `entry.type`). + const actor = useAgentOsActor(); + const useAgentEvent = actor.useEvent as ( + name: string, + handler: (payload: unknown) => void, + ) => void; + useAgentEvent("sessionEvent", (payload) => { + const entry = payload as SessionStreamEntry | undefined; + if (!entry) return; + if (entry.type === "permission_request") { + if (!entry.sessionId || !entry.requestId) return; + const next: PermissionCard = { + sessionId: entry.sessionId, + requestId: entry.requestId, + options: entry.options, + toolCall: entry.toolCall, + state: "pending", + }; + setCards((prev) => { + const deduped = prev.filter((c) => cardKey(c) !== cardKey(next)); + return [...deduped, next].slice(-MAX_CARDS); + }); + return; + } + // Another viewer (or a headless client) answered: its card is stale here, + // so drop it outright — an untouched card needs no "handled elsewhere" + // residue. A busy card stays: its own in-flight respond reports the + // outcome (`{status: "not_pending"}` → quiet resolved state). + if (entry.type === "permission_response") { + if (!entry.sessionId || !entry.requestId) return; + const key = cardKey(entry); + setCards((prev) => prev.filter((c) => cardKey(c) !== key || c.state === "busy")); + } + }); + + const respond = async (card: PermissionCard, optionId: string) => { + const key = cardKey(card); + const patch = (changes: Partial) => + setCards((prev) => prev.map((c) => (cardKey(c) === key ? { ...c, ...changes } : c))); + patch({ state: "busy" }); + try { + const result = await agentOsSource.respondPermission( + card.sessionId, + card.requestId, + optionId, + ); + if (result.status === "accepted") { + setCards((prev) => prev.filter((c) => cardKey(c) !== key)); + return; + } + // `not_pending`: another viewer answered first or the prompt ended — + // quiet outcome, not an error card. + patch({ state: "resolved", reason: result.reason }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const hint = isInspectorActionError(error) ? ` — ${error.hint}` : ""; + patch({ state: "failed", error: `${message}${hint}` }); + } + }; + + const dismiss = (card: PermissionCard) => + setCards((prev) => prev.filter((c) => cardKey(c) !== cardKey(card))); + + if (cards.length === 0) return null; + + return ( +
+ {cards.map((card) => { + const detail = toolCallDetail(card.toolCall); + return ( +
+
+
+
+ Agent requests permission + + session …{card.sessionId.slice(-10)} + +
+
+ {card.toolCall?.title ?? card.requestId} +
+ {detail !== undefined ? ( +
+ + + Details + +
+											{JSON.stringify(detail, null, 2)}
+										
+
+ ) : null} +
+ {card.state === "pending" || card.state === "busy" ? ( +
+ {card.options.map((option) => ( + + ))} +
+ ) : ( +
+ + {card.state === "resolved" + ? `No longer pending (${(card.reason ?? "already resolved").replace(/_/g, " ")})` + : (card.error ?? "Reply failed")} + + +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/packages/agentos/src/inspector-tabs/software-logos.ts b/packages/agentos/src/inspector-tabs/software-logos.ts new file mode 100644 index 0000000000..0bcab2dc30 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/software-logos.ts @@ -0,0 +1,80 @@ +// Package logos, vendored from the registry site +// (website/public/images/registry) so the inspector works offline. Keyed by +// the package basename (SoftwareBundle.slug); packages without a logo fall +// back to a letter avatar in the Software list. +import browserbase from "./assets/software-logos/browserbase.svg"; +import claudeCode from "./assets/software-logos/claude-code.svg"; +import codex from "./assets/software-logos/codex.svg"; +import coreutils from "./assets/software-logos/coreutils.svg"; +import curl from "./assets/software-logos/curl.svg"; +import diffutils from "./assets/software-logos/diffutils.svg"; +import duckdb from "./assets/software-logos/duckdb.svg"; +import findutils from "./assets/software-logos/findutils.svg"; +import gawk from "./assets/software-logos/gawk.svg"; +import git from "./assets/software-logos/git.svg"; +import grep from "./assets/software-logos/grep.svg"; +import gzip from "./assets/software-logos/gzip.svg"; +import jq from "./assets/software-logos/jq.svg"; +import nodejs from "./assets/software-logos/nodejs.svg"; +import opencode from "./assets/software-logos/opencode.svg"; +import pi from "./assets/software-logos/pi.svg"; +import python from "./assets/software-logos/python.svg"; +import sed from "./assets/software-logos/sed.svg"; +import sqlite3 from "./assets/software-logos/sqlite3.svg"; +import superMemory from "./assets/software-logos/super-memory.svg"; +import tar from "./assets/software-logos/tar.svg"; +import vim from "./assets/software-logos/vim.svg"; +import wget from "./assets/software-logos/wget.svg"; + +/** Dark-mode legibility per logo, hue-true: brightness lifts dark brand colors + * without shifting them (GNU red stays red, curl navy stays navy); invert is + * reserved for near-black monochrome marks, which have no hue to break. + * Colorful logos (git, python, node, …) need nothing. */ +export const SOFTWARE_LOGO_DARK_CLASS: Record = { + // GNU family, #A42E2B dark red. + coreutils: "dark:brightness-[1.8]", + diffutils: "dark:brightness-[1.8]", + findutils: "dark:brightness-[1.8]", + gawk: "dark:brightness-[1.8]", + grep: "dark:brightness-[1.8]", + gzip: "dark:brightness-[1.8]", + sed: "dark:brightness-[1.8]", + tar: "dark:brightness-[1.8]", + wget: "dark:brightness-[1.8]", + // Deep navy / purple marks. + codex: "dark:brightness-[2.1]", + curl: "dark:brightness-[2.6]", + sqlite3: "dark:brightness-[2.6]", + // Near-black monochrome marks. + duckdb: "dark:invert", + jq: "dark:invert", + opencode: "dark:invert", + pi: "dark:invert", +}; + +export const SOFTWARE_LOGOS: Record = { + browserbase, + "claude-code": claudeCode, + codex, + coreutils, + curl, + diffutils, + duckdb, + findutils, + gawk, + git, + grep, + gzip, + jq, + node: nodejs, + nodejs, + opencode, + pi, + python, + sed, + sqlite3, + "super-memory": superMemory, + tar, + vim, + wget, +}; diff --git a/packages/agentos/src/inspector-tabs/styles.css b/packages/agentos/src/inspector-tabs/styles.css index 5ba1d9a4a4..c6cf7a9a8b 100644 --- a/packages/agentos/src/inspector-tabs/styles.css +++ b/packages/agentos/src/inspector-tabs/styles.css @@ -46,6 +46,12 @@ body, #root { height: 100%; margin: 0; + /* The tab app is a fixed-viewport UI (every pane scrolls internally). A + page-level scrollbar must never appear: with classic (space-consuming) + scrollbars, transient overflow during streamed output triggers a + vertical-bar → narrower content → horizontal-bar → refit → retract + feedback loop that visibly flashes both scrollbars. */ + overflow: hidden; } body { diff --git a/packages/agentos/src/inspector-tabs/svg.d.ts b/packages/agentos/src/inspector-tabs/svg.d.ts new file mode 100644 index 0000000000..e5d5dc958e --- /dev/null +++ b/packages/agentos/src/inspector-tabs/svg.d.ts @@ -0,0 +1,6 @@ +// Vite emits imported SVGs as hashed asset files; the default export is the +// URL. (tsconfig has "types": [], so vite/client is not in scope.) +declare module "*.svg" { + const url: string; + export default url; +} diff --git a/packages/agentos/src/inspector-tabs/tab-boundary.tsx b/packages/agentos/src/inspector-tabs/tab-boundary.tsx index c4cf411a55..e3e77b2854 100644 --- a/packages/agentos/src/inspector-tabs/tab-boundary.tsx +++ b/packages/agentos/src/inspector-tabs/tab-boundary.tsx @@ -1,5 +1,8 @@ +import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { Component, type ReactNode, Suspense } from "react"; +import { ActionErrorNote, UnsupportedAction } from "./common"; +import { isInspectorActionError } from "./lib/actor-client"; import React from "react"; function TabFallback() { @@ -16,27 +19,53 @@ function TabFallback() { ); } -class ErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> { +class ErrorBoundary extends Component< + { children: ReactNode; onReset?: () => void }, + { error?: Error } +> { state: { error?: Error } = {}; static getDerivedStateFromError(error: Error) { return { error }; } + #retry = () => { + this.props.onReset?.(); + this.setState({ error: undefined }); + }; render() { - if (this.state.error) { - return ( -
- {this.state.error.message || "Failed to load."} -
- ); + const { error } = this.state; + if (!error) return this.props.children; + // Contract-layer failures are a capability gap, not a fault: render the + // quiet unsupported state with no retry (retrying cannot succeed). + if (isInspectorActionError(error) && error.layer === "contract") { + return ; } - return this.props.children; + return ( +
+
+ + +
+
+ ); } } export function TabBoundary({ children }: { children: ReactNode }) { + // QueryErrorResetBoundary clears React Query's cached suspense errors so the + // Retry button actually refetches instead of re-throwing the stale error. return ( - - }>{children} - + + {({ reset }) => ( + + }>{children} + + )} + ); } diff --git a/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx b/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx index 9385240db3..44e04953d1 100644 --- a/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx +++ b/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx @@ -1,26 +1,53 @@ -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; -import { AgentOsEmpty, ChevronRight, FileGlyph, formatBytes, relativeTime } from "../common"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActionErrorNote, + AgentOsEmpty, + AgentOsWordmark, + ArrowLeftIcon, + CheckIcon, + ChevronRight, + DownloadIcon, + FileGlyph, + FolderPlusIcon, + formatBytes, + IconButton, + PencilIcon, + RefreshIcon, + relativeTime, + TrashIcon, + UploadIcon, +} from "../common"; import { cn } from "../lib/cn"; import { agentOsSource } from "../lib/source"; import type { FsEntry } from "../lib/types"; import { ScrollArea } from "../ui/scroll-area"; +import { VmBootGate } from "../vm-boot-gate"; +import { VmStatusBadges } from "../vm-status-badges"; import React from "react"; +const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|avif)$/i; + function FileTreeItem({ actorId, entry, depth, selectedPath, onSelect, + openPaths, + onToggle, }: { actorId: string; entry: FsEntry; depth: number; selectedPath: string | null; onSelect: (e: FsEntry) => void; + /** Expansion lives in the parent (persisted): it must survive the per-tab + * iframe swap, which unmounts this whole tree. */ + openPaths: ReadonlySet; + onToggle: (path: string) => void; }) { - const [open, setOpen] = useState(false); + const open = openPaths.has(entry.path); const isSelected = entry.path === selectedPath; const expandable = entry.dir && !entry.virtual; const childrenQuery = useQuery( @@ -32,7 +59,7 @@ function FileTreeItem({
- {expandable && open ? ( - childrenQuery.isLoading ? ( -
- loading… -
- ) : ( - (childrenQuery.data ?? []).map((child) => ( + {/* No loading row: most directories list in well under a frame, so a + transient "loading…" reads as a glitch. Children simply appear. */} + {expandable && open + ? (childrenQuery.data ?? []).map((child) => ( )) - ) - ) : null} + : null} ); } -function FileViewer({ actorId, path }: { actorId: string; path: string | null }) { - const { data, error, isFetching } = useQuery( - agentOsSource.fileContentQueryOptions(actorId, path), +function downloadBytes(bytes: Uint8Array, filename: string): void { + const url = URL.createObjectURL(new Blob([bytes as BlobPart])); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +function FileViewer({ + actorId, + path, + onMutated, + onDeleted, + onRenamed, +}: { + actorId: string; + path: string | null; + onMutated: () => void; + onDeleted: () => void; + onRenamed: (to: string) => void; +}) { + const [force, setForce] = useState(false); + const [renameDraft, setRenameDraft] = useState(null); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [mutationError, setMutationError] = useState(null); + // Per-file view state resets when the selection changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset on path change + useEffect(() => { + setForce(false); + setRenameDraft(null); + setConfirmingDelete(false); + setMutationError(null); + }, [path]); + const { data, error } = useQuery( + agentOsSource.fileContentQueryOptions(actorId, path, force), ); - if (!path) return Select a file to view its contents.; - if (error) return Failed to read {path}: {(error as Error).message}; - if (!data || isFetching) return Loading {path}…; + const imageUrl = useMemo(() => { + if (!data?.bytes || data.text !== null || !IMAGE_EXTENSIONS.test(data.path)) return null; + return URL.createObjectURL(new Blob([data.bytes as BlobPart])); + }, [data]); + useEffect(() => { + return () => { + if (imageUrl) URL.revokeObjectURL(imageUrl); + }; + }, [imageUrl]); + + if (!path) + return ( + +
+ + Select a file to view its contents. +
+
+ ); + if (error) return ; + // Only the very first open shows a loading state; switching files keeps the + // previous content until the new one lands (keepPreviousData in source.ts). + if (!data) return Loading {path}…; + + const filename = data.path.split("/").pop() ?? data.path; + const rename = async () => { + const to = renameDraft?.trim(); + if (!to || to === data.path) { + setRenameDraft(null); + return; + } + setMutationError(null); + try { + await agentOsSource.moveEntry(data.path, to); + setRenameDraft(null); + onRenamed(to); + onMutated(); + } catch (err) { + setMutationError(err); + } + }; + const remove = async () => { + if (!confirmingDelete) { + setConfirmingDelete(true); + setTimeout(() => setConfirmingDelete(false), 3_000); + return; + } + setMutationError(null); + try { + await agentOsSource.deleteFile(data.path, {}); + onDeleted(); + onMutated(); + } catch (err) { + setMutationError(err); + } + }; + return (
-
- {data.path} - {formatBytes(data.sizeBytes)} +
+ {renameDraft !== null ? ( + setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void rename(); + if (e.key === "Escape") setRenameDraft(null); + }} + spellCheck={false} + autoFocus + aria-label="New path" + className="min-w-0 flex-1 rounded border bg-background px-2 py-1 font-mono text-xs focus:outline-none" + /> + ) : ( + {data.path} + )} + {formatBytes(data.sizeBytes)} {relativeTime(data.mtimeMs)} + data.bytes && downloadBytes(data.bytes, filename)} + > + + + (renameDraft !== null ? void rename() : setRenameDraft(data.path))} + > + {renameDraft !== null ? ( + + ) : ( + + )} + + {/* Delete keeps a two-step confirm: the icon arms it, the explicit + text disarms accidental clicks on an irreversible action. */} + {confirmingDelete ? ( + + ) : ( + void remove()}> + + + )}
+ {mutationError ? : null} - {data.text === null ? ( + {data.special ? (
- Binary file ({formatBytes(data.sizeBytes)}) — preview unavailable. + Device or stream — no readable contents. +
+ ) : data.oversize ? ( +
+ + Large file ({formatBytes(data.sizeBytes)}) — preview skipped to avoid dragging it + through the gateway. + +
+ ) : data.text === null ? ( + imageUrl ? ( +
+ {filename} +
+ ) : ( +
+ Binary file ({formatBytes(data.sizeBytes)}) — use Download to save it. +
+ ) ) : (
 						{data.text}
@@ -111,6 +308,12 @@ function FileViewer({ actorId, path }: { actorId: string; path: string | null })
 
 /** Normalize a user-typed root: ensure a single leading slash, collapse
  * repeated slashes, and drop a trailing slash (except for root itself). */
+/** Parent directory of a path ("/" for top-level entries). */
+function parentDir(path: string): string {
+	const idx = path.lastIndexOf("/");
+	return idx <= 0 ? "/" : path.slice(0, idx);
+}
+
 function normalizeRoot(input: string): string {
 	let s = input.trim();
 	if (s === "") return "/";
@@ -120,13 +323,95 @@ function normalizeRoot(input: string): string {
 	return s;
 }
 
+const joinRoot = (root: string, name: string) => (root === "/" ? `/${name}` : `${root}/${name}`);
+
 export function FilesystemTabConnected({ actorId }: { actorId: string }) {
+	// The root filesystem is in-memory and served by the VM's kernel: a
+	// sleeping VM has no file tree, and listing would boot it. Gate first.
+	return (
+		
+			
+		
+	);
+}
+
+// Browsing state survives tab switches: the dashboard swaps the iframe per
+// tab, so the root path, open file, and expanded directories persist per
+// actor.
+const fsStateKey = (actorId: string) => `agentos-inspector:fs:${actorId}`;
+// Expansion is bounded so a long browsing session cannot bloat the payload;
+// oldest-expanded entries drop first (Set iteration is insertion-ordered).
+const MAX_PERSISTED_OPEN_DIRS = 200;
+
+function loadFsState(actorId: string): {
+	root: string;
+	selectedPath: string | null;
+	openPaths: string[];
+} {
+	try {
+		const raw = sessionStorage.getItem(fsStateKey(actorId));
+		if (raw) {
+			const parsed = JSON.parse(raw) as {
+				root?: unknown;
+				selectedPath?: unknown;
+				openPaths?: unknown;
+			};
+			return {
+				root: typeof parsed.root === "string" ? parsed.root : "/",
+				selectedPath: typeof parsed.selectedPath === "string" ? parsed.selectedPath : null,
+				openPaths: Array.isArray(parsed.openPaths)
+					? parsed.openPaths.filter((p): p is string => typeof p === "string")
+					: [],
+			};
+		}
+	} catch {
+		// Malformed storage falls back to defaults.
+	}
+	return { root: "/", selectedPath: null, openPaths: [] };
+}
+
+function FilesystemLoaded({ actorId }: { actorId: string }) {
 	// `root` drives the listing/refetch; `draft` tracks keystrokes locally so
 	// typing never refetches. `root` is committed 500ms after typing stops (or
 	// immediately on Enter) so we don't refetch on every keystroke.
-	const [root, setRoot] = useState("/");
-	const [draft, setDraft] = useState("/");
-	const [selectedPath, setSelectedPath] = useState(null);
+	const initial = useRef(loadFsState(actorId)).current;
+	const [root, setRoot] = useState(initial.root);
+	const [draft, setDraft] = useState(initial.root);
+	const [selectedPath, setSelectedPath] = useState(initial.selectedPath);
+	const [openPaths, setOpenPaths] = useState>(
+		() => new Set(initial.openPaths),
+	);
+	const toggleOpen = (path: string) =>
+		setOpenPaths((prev) => {
+			const next = new Set(prev);
+			if (next.has(path)) next.delete(path);
+			else {
+				next.add(path);
+				if (next.size > MAX_PERSISTED_OPEN_DIRS) {
+					const oldest = next.values().next().value;
+					if (oldest !== undefined) next.delete(oldest);
+				}
+			}
+			return next;
+		});
+	useEffect(() => {
+		try {
+			sessionStorage.setItem(
+				fsStateKey(actorId),
+				JSON.stringify({ root, selectedPath, openPaths: [...openPaths] }),
+			);
+		} catch (error) {
+			console.warn("agentos inspector: failed to persist filesystem state", error);
+		}
+	}, [actorId, root, selectedPath, openPaths]);
+	const [newFolderDraft, setNewFolderDraft] = useState(null);
+	const [treeError, setTreeError] = useState(null);
+	const uploadInputRef = useRef(null);
+	const queryClient = useQueryClient();
 
 	const rootsQuery = useQuery(agentOsSource.listDirQueryOptions(actorId, root));
 	// `null` data = the path is not a listable directory (does not exist / is a
@@ -143,10 +428,89 @@ export function FilesystemTabConnected({ actorId }: { actorId: string }) {
 		return () => clearTimeout(id);
 	}, [draft]);
 
+	// Every directory listing under this actor (the tree fetches per-level).
+	const refreshTree = () =>
+		queryClient.invalidateQueries({ queryKey: ["agent-os", actorId, "dir"] });
+
+	// Folder actions target the location shown in the path bar, which follows
+	// tree clicks (a folder moves it there; a file moves it to its parent).
+	const currentDir = normalizeRoot(draft);
+
+	const createFolder = async () => {
+		const name = newFolderDraft?.trim();
+		if (!name) {
+			setNewFolderDraft(null);
+			return;
+		}
+		setTreeError(null);
+		try {
+			await agentOsSource.mkdir(name.startsWith("/") ? name : joinRoot(currentDir, name));
+			setNewFolderDraft(null);
+			await refreshTree();
+		} catch (error) {
+			setTreeError(error);
+		}
+	};
+
+	const upload = async (file: File) => {
+		setTreeError(null);
+		try {
+			const bytes = new Uint8Array(await file.arrayBuffer());
+			await agentOsSource.writeFile(joinRoot(currentDir, file.name), bytes);
+			await refreshTree();
+		} catch (error) {
+			setTreeError(error);
+		}
+	};
+
 	return (
 		
-
-
+
+
+ Files + + void refreshTree()}> + + + setNewFolderDraft((v) => (v === null ? "" : null))} + > + + + uploadInputRef.current?.click()} + > + + + { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) void upload(file); + }} + /> +
+ {/* The browsing root gets its own bar: sharing the header row with + the action icons left it truncated and cramped. Clicking into a + folder moves the bar there, so the arrow walks back up. */} +
+ { + const up = parentDir(currentDir); + setDraft(up); + setRoot((cur) => (up !== cur ? up : cur)); + }} + className="size-5" + > + + setDraft(e.target.value)} @@ -164,17 +528,38 @@ export function FilesystemTabConnected({ actorId }: { actorId: string }) { className="w-full bg-transparent font-mono text-xs text-muted-foreground outline-none placeholder:text-muted-foreground/40 focus:text-foreground" />
+ {newFolderDraft !== null ? ( +
+ setNewFolderDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void createFolder(); + if (e.key === "Escape") setNewFolderDraft(null); + }} + spellCheck={false} + autoFocus + aria-label="New folder name" + placeholder={`folder name (created under ${currentDir})`} + className="w-full rounded border bg-background px-2 py-1 font-mono text-xs focus:outline-none" + /> +
+ ) : null} + {treeError ? : null} {rootsQuery.isLoading ? ( Loading {root}… ) : rootsQuery.error ? ( - - Failed to list {root}: {(rootsQuery.error as Error).message} - + ) : notADir ? ( Not a directory, or does not exist: {root} ) : roots.length === 0 ? ( - Empty directory. + +
+ + Empty directory. +
+
) : ( roots.map((entry) => ( setSelectedPath(e.path)} + onSelect={(e) => { + if (e.dir) { + setDraft(e.path); + } else { + setSelectedPath(e.path); + setDraft(parentDir(e.path)); + } + }} + openPaths={openPaths} + onToggle={toggleOpen} /> )) )}
-
- +
+ {/* VM trouble chips float over the viewer, below its header row so + they never cover the file action buttons; the dropdown has the + full pane width to open into (it clipped inside the sidebar). */} +
+ +
+ void refreshTree()} + onDeleted={() => setSelectedPath(null)} + onRenamed={(to) => setSelectedPath(to)} + />
); diff --git a/packages/agentos/src/inspector-tabs/tabs/mounts.tsx b/packages/agentos/src/inspector-tabs/tabs/mounts.tsx deleted file mode 100644 index a0ee48686c..0000000000 --- a/packages/agentos/src/inspector-tabs/tabs/mounts.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { AgentOsEmpty, CopyButton } from "../common"; -import { agentOsSource } from "../lib/source"; -import type { MountInfo } from "../lib/types"; -import { Badge } from "../ui/badge"; -import { ScrollArea } from "../ui/scroll-area"; -import React from "react"; - -const isEmptyConfig = (config: undefined | null | any): config is null | undefined => { - return typeof config === "undefined" || config === null || Object.keys(config).length === 0; -} - -export function MountsTab({ mounts }: { mounts: MountInfo[] }) { - return ( -
- {mounts.length === 0 ? ( - No mounts configured on this actor. - ) : ( - - - - - - - - - - - - {mounts.map((m) => ( - - - - - - - ))} - -
PathKindAccessConfig
{m.path} - - {m.kind} - - - {m.readOnly ? "read-only" : "read-write"} - - {isEmptyConfig(m.config) ? ( - - ) : ( -
- - {JSON.stringify(m.config)} - - -
- )} -
-
- )} -
- ); -} - -export function MountsTabConnected({ actorId }: { actorId: string }) { - const { data } = useSuspenseQuery(agentOsSource.mountsQueryOptions(actorId)); - return ; -} diff --git a/packages/agentos/src/inspector-tabs/tabs/processes.tsx b/packages/agentos/src/inspector-tabs/tabs/processes.tsx index 8f8bb974b3..790510078e 100644 --- a/packages/agentos/src/inspector-tabs/tabs/processes.tsx +++ b/packages/agentos/src/inspector-tabs/tabs/processes.tsx @@ -1,9 +1,13 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { useState } from "react"; -import { AgentOsEmpty, StatusDot } from "../common"; -import { agentOsSource } from "../lib/source"; -import type { ProcessInfo } from "../lib/types"; -import { ScrollArea } from "../ui/scroll-area"; +// Kernel process table for the System tab: one dense table sized to content, +// with detail on demand — clicking a row expands it inline (fields, stop/kill, +// live output tail) instead of a permanently open side pane. +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import { ActionErrorNote, ChevronRight, relativeTime, StatusDot } from "../common"; +import { cn } from "../lib/cn"; +import { useAgentOsActor } from "../lib/rivet"; +import { agentOsSource, decodeActionBytes } from "../lib/source"; +import type { KernelProcessInfo, ProcessExitPayload, ProcessOutputPayload, ProcessTreeNode } from "../lib/types"; import React from "react"; /** Format an epoch-ms spawn time for display; `—` when absent. */ @@ -12,87 +16,266 @@ function formatStartedAt(startedAt: number | undefined): string { return new Date(startedAt).toLocaleTimeString(); } -function ProcessDetail({ p }: { p: ProcessInfo }) { - const rows: [string, string][] = [ - ["pid", String(p.pid)], - ["command", p.command], - ["args", p.args.join(" ") || "—"], - ["status", p.running ? "running" : "exited"], - ["exit code", p.exitCode == null ? "—" : String(p.exitCode)], - ["started", formatStartedAt(p.startedAt)], - ]; +/** Tree → rows with depth for indentation. Running processes sort before + * exited at every level; ties break newest-first so fresh activity is on top. */ +interface ProcessRow extends KernelProcessInfo { + depth: number; +} + +function flattenTree(nodes: ProcessTreeNode[], depth = 0, out: ProcessRow[] = []): ProcessRow[] { + const sorted = [...nodes].sort( + (a, b) => + Number(b.status === "running") - Number(a.status === "running") || + b.startTime - a.startTime, + ); + for (const node of sorted) { + const { children, ...info } = node; + out.push({ ...info, depth }); + flattenTree(children, depth + 1, out); + } + return out; +} + +// ── Live output tail ─────────────────────────────────────────────────────── +// Bounded per-pid buffers fed by the `processOutput` broadcast. Only pids +// spawned through the SDK have output pumps; everything else shows nothing. +const MAX_TRACKED_PIDS = 32; +const MAX_BUFFER_CHARS = 64_000; + +class OutputBuffers { + private buffers = new Map(); + + append(pid: number, text: string): void { + const prev = this.buffers.get(pid); + if (prev === undefined && this.buffers.size >= MAX_TRACKED_PIDS) { + // Bounded: drop the oldest-tracked pid's buffer. + const oldest = this.buffers.keys().next().value; + if (oldest !== undefined) this.buffers.delete(oldest); + } + const next = (prev ?? "") + text; + this.buffers.set(pid, next.length > MAX_BUFFER_CHARS ? next.slice(-MAX_BUFFER_CHARS) : next); + } + + get(pid: number): string | undefined { + return this.buffers.get(pid); + } +} + +/** Label-over-value cell for the expanded detail grid — compact, no divider + * rows, the layout VM dashboards use for inspect summaries. */ +function Field({ label, value }: { label: string; value: string }) { return ( -
-
- pid {p.pid} - {p.command} +
+
{label}
+
+ {value}
- -
- {rows.map(([key, val]) => ( -
-
{key}
-
{val}
-
- ))} -
-
); } -export function ProcessesTabConnected({ actorId }: { actorId: string }) { - const { data } = useSuspenseQuery(agentOsSource.processesQueryOptions(actorId)); - const [selectedPid, setSelectedPid] = useState(); - const selected = data.find((p) => p.pid === selectedPid) ?? data[0]; - +function ExpandedDetail({ + p, + outputTail, + onStop, + onKill, + actionError, +}: { + p: ProcessRow; + outputTail?: string; + onStop: () => void; + onKill: () => void; + actionError: unknown; +}) { + const [confirming, setConfirming] = useState<"stop" | "kill" | null>(null); + const confirmTimer = useRef | undefined>(undefined); + const arm = (which: "stop" | "kill", run: () => void) => { + if (confirming === which) { + clearTimeout(confirmTimer.current); + setConfirming(null); + run(); + return; + } + setConfirming(which); + clearTimeout(confirmTimer.current); + confirmTimer.current = setTimeout(() => setConfirming(null), 3_000); + }; return ( -
- {data.length === 0 ? ( - No processes running. +
+
+ + + + + + + + +
+ {p.status === "running" ? ( +
+ + +
+ ) : null} + {actionError ? : null} + {outputTail ? ( +
+
Output (live tail)
+
+						{outputTail}
+					
+
) : ( -
- - - - - - - - {/* */} - {/* */} - - - - - {data.map((p) => ( - setSelectedPid(p.pid)} - className={`cursor-pointer border-b border-foreground/[0.06] hover:bg-muted/50 ${ - selected?.pid === p.pid ? "bg-muted/50" : "" - }`} - > - - - - {/* mem, cpu */} - - - ))} - -
PIDCommandStartedMemCPUStatus
{p.pid}{p.command} {p.args.join(" ")}{formatStartedAt(p.startedAt)} - - - {p.running ? "running" : "exited"} - -
-
-
- {selected ? : Select a process.} -
+
+ No live output. Only processes spawned through the SDK stream stdout/stderr here.
)}
); } + +/** Running/total counts for the System overview; same query key as the table + * so React Query serves both from one fetch. */ +export function useProcessCounts(actorId: string): { running: number; total: number } { + const { data: tree } = useSuspenseQuery(agentOsSource.processTreeQueryOptions(actorId)); + const rows = flattenTree(tree); + return { running: rows.filter((p) => p.status === "running").length, total: rows.length }; +} + +export function ProcessTable({ actorId }: { actorId: string }) { + const { data: tree } = useSuspenseQuery(agentOsSource.processTreeQueryOptions(actorId)); + const queryClient = useQueryClient(); + const [expandedPid, setExpandedPid] = useState(null); + const [actionError, setActionError] = useState(null); + + const rows = flattenTree(tree); + + // Live output tail for SDK-spawned pids; exit refreshes the table. + const buffersRef = useRef(new OutputBuffers()); + const [, setOutputVersion] = useState(0); + const actor = useAgentOsActor(); + const useAgentEvent = actor.useEvent as ( + name: string, + handler: (payload: unknown) => void, + ) => void; + useAgentEvent("processOutput", (raw) => { + const payload = raw as ProcessOutputPayload | undefined; + if (!payload || typeof payload.pid !== "number") return; + const text = new TextDecoder("utf-8", { fatal: false }).decode( + decodeActionBytes(payload.data), + ); + if (!text) return; + buffersRef.current.append(payload.pid, text); + setOutputVersion((v) => v + 1); + }); + useAgentEvent("processExit", (raw) => { + const payload = raw as ProcessExitPayload | undefined; + if (!payload || typeof payload.pid !== "number") return; + buffersRef.current.append(payload.pid, `\n[exited ${payload.exitCode}]`); + setOutputVersion((v) => v + 1); + void queryClient.invalidateQueries({ + queryKey: agentOsSource.processTreeQueryOptions(actorId).queryKey, + }); + }); + + const invalidate = () => + queryClient.invalidateQueries({ + queryKey: agentOsSource.processTreeQueryOptions(actorId).queryKey, + }); + const runControl = async (action: () => Promise) => { + setActionError(null); + try { + await action(); + await invalidate(); + } catch (error) { + setActionError(error); + } + }; + + if (rows.length === 0) { + return
No processes in the VM.
; + } + return ( +
+ + + + + + + + + + + {rows.map((p) => ( + + setExpandedPid((cur) => (cur === p.pid ? null : p.pid))} + className={cn( + "cursor-pointer border-b border-foreground/[0.06] hover:bg-muted/50", + expandedPid === p.pid && "bg-muted/40", + p.status === "exited" && "opacity-50", + )} + > + + + + + + + {expandedPid === p.pid ? ( + + + + ) : null} + + ))} + +
+ PIDCommandStartedStatus
+ + {p.pid} + + {p.depth > 0 ? : null} + {p.command} + {p.args.length > 0 ? ( + {p.args.join(" ")} + ) : null} + + + {formatStartedAt(p.startTime)} + + + + {p.status} + +
+ void runControl(() => agentOsSource.stopProcess(p.pid))} + onKill={() => void runControl(() => agentOsSource.killProcess(p.pid))} + actionError={actionError} + /> +
+
+ ); +} diff --git a/packages/agentos/src/inspector-tabs/tabs/software.tsx b/packages/agentos/src/inspector-tabs/tabs/software.tsx deleted file mode 100644 index 317b158f90..0000000000 --- a/packages/agentos/src/inspector-tabs/tabs/software.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { useState } from "react"; -import { AgentOsEmpty, ChevronRight } from "../common"; -import { cn } from "../lib/cn"; -import { agentOsSource } from "../lib/source"; -import type { SoftwareBundle } from "../lib/types"; -import { Badge } from "../ui/badge"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "../ui/collapsible"; -import { ScrollArea } from "../ui/scroll-area"; -import React from "react"; - -function SoftwareRow({ bundle }: { bundle: SoftwareBundle }) { - const [open, setOpen] = useState(false); - const hasBinaries = bundle.binaries.length > 0; - - return ( - - - - {bundle.name} - {hasBinaries ? ( - - {bundle.binaries.length} cmd{bundle.binaries.length === 1 ? "" : "s"} - - ) : null} - - {bundle.version} - - - {bundle.source} - - - {hasBinaries ? ( - -
- {bundle.binaries.map((bin) => ( - - {bin} - - ))} -
-
- ) : null} -
- ); -} - -export function SoftwareTab({ software }: { software: SoftwareBundle[] }) { - return ( -
- {software.length === 0 ? ( - No software bundles installed. - ) : ( - -
- {software.map((bundle) => ( - - ))} -
-
- )} -
- ); -} - -export function SoftwareTabConnected({ actorId }: { actorId: string }) { - const { data } = useSuspenseQuery(agentOsSource.softwareQueryOptions(actorId)); - return ; -} diff --git a/packages/agentos/src/inspector-tabs/tabs/system.tsx b/packages/agentos/src/inspector-tabs/tabs/system.tsx new file mode 100644 index 0000000000..6f2c51c651 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/tabs/system.tsx @@ -0,0 +1,322 @@ +// "What is this VM made of and what is it doing": the live process tree plus +// installed software, configured mounts, preview links, and the actor id in +// one scroll view, keeping the tab bar to the high-traffic surfaces +// (transcript, filesystem). +import { useSuspenseQueries } from "@tanstack/react-query"; +import { type ReactNode, useState } from "react"; +import { ActionErrorNote, ChevronRight, CopyButton } from "../common"; +import { cn } from "../lib/cn"; +import { agentOsSource } from "../lib/source"; +import type { MountInfo, SignedPreviewUrl, SoftwareBundle } from "../lib/types"; +import { Badge } from "../ui/badge"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; +import { SOFTWARE_LOGO_DARK_CLASS, SOFTWARE_LOGOS } from "../software-logos"; +import { ScrollArea } from "../ui/scroll-area"; +import { VmBootGate } from "../vm-boot-gate"; +import { VmStatusBadges } from "../vm-status-badges"; +import { ProcessTable, useProcessCounts } from "./processes"; +import React from "react"; + +function SoftwareRow({ bundle }: { bundle: SoftwareBundle }) { + const [open, setOpen] = useState(false); + const hasBinaries = bundle.binaries.length > 0; + const logo = SOFTWARE_LOGOS[bundle.slug]; + + return ( + + + + {/* Theme-aware chip: white in light mode (the logos' native + background), muted in dark mode with a per-logo hue-true + treatment so dark artwork stays legible. */} + + {logo ? ( + + ) : ( + + {bundle.slug.charAt(0)} + + )} + + {bundle.name} + {hasBinaries ? ( + + {bundle.binaries.length} cmd{bundle.binaries.length === 1 ? "" : "s"} + + ) : null} + {bundle.version} + + {bundle.source} + + + {hasBinaries ? ( + +
+ {bundle.binaries.map((bin) => ( + + {bin} + + ))} +
+
+ ) : null} +
+ ); +} + +const isEmptyConfig = (config: unknown): config is null | undefined => + typeof config === "undefined" || + config === null || + Object.keys(config as object).length === 0; + +function MountsTable({ mounts }: { mounts: MountInfo[] }) { + return ( + + + + + + + + + + + {mounts.map((m) => ( + + + + + + + ))} + +
PathKindAccessConfig
{m.path} + + {m.kind} + + + {m.readOnly ? "read-only" : "read-write"} + + {isEmptyConfig(m.config) ? ( + + ) : ( +
+ + {JSON.stringify(m.config)} + + +
+ )} +
+ ); +} + +// Signed preview links: proxy HTTP to a port inside the VM. Local state only — +// links created here are revocable here; links created by code are not listed +// (there is no enumeration action). +function PreviewLinks() { + const [portDraft, setPortDraft] = useState("3000"); + const [links, setLinks] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const create = async () => { + const port = Number.parseInt(portDraft, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + setError(new Error(`Not a valid port: ${portDraft}`)); + return; + } + setBusy(true); + setError(null); + try { + const link = await agentOsSource.createSignedPreviewUrl(port, 900); + setLinks((prev) => [...prev.filter((l) => l.token !== link.token), link]); + } catch (err) { + setError(err); + } finally { + setBusy(false); + } + }; + const revoke = async (link: SignedPreviewUrl) => { + setError(null); + try { + await agentOsSource.expireSignedPreviewUrl(link.token); + setLinks((prev) => prev.filter((l) => l.token !== link.token)); + } catch (err) { + setError(err); + } + }; + + return ( +
+
+ + setPortDraft(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && void create()} + inputMode="numeric" + className="w-20 rounded border bg-background px-2 py-1 font-mono focus:outline-none" + /> + + + Signed URL to an HTTP server on that port, valid 15 minutes. Boots the VM if asleep. + +
+ {error ? : null} + {links.length > 0 ? ( +
+ {links.map((link) => ( +
+ + port {link.port}: {link.path} + + + expires {new Date(link.expiresAt).toLocaleTimeString()} + + +
+ ))} +
+ ) : null} +
+ ); +} + +/** Card section, matching the dashboard's settings idiom: title inside a + * rounded bordered card, tables in an inner border. */ +function Card({ + title, + right, + children, +}: { + title: string; + right?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {title} + + {right} +
+ {children} +
+ ); +} + +export function SystemTabConnected({ actorId }: { actorId: string }) { + // Every section here (process tree, software commands, mounts) is + // enumerated by the running VM, so opening the tab would wake a sleeping + // one. Gate first. + return ( + + + + ); +} + +function SystemLoaded({ actorId }: { actorId: string }) { + const [software, mounts] = useSuspenseQueries({ + queries: [ + agentOsSource.softwareQueryOptions(actorId), + agentOsSource.mountsQueryOptions(actorId), + ], + }); + const processCounts = useProcessCounts(actorId); + const count = (text: string) => ( + {text} + ); + return ( +
+ {/* VM trouble chips float top-right like every other tab; nothing + renders while the VM is healthy. */} +
+ +
+ +
+ +
+ +
+
+ + {software.data.length === 0 ? ( +
No software bundles installed.
+ ) : ( +
+ {software.data.map((bundle) => ( + + ))} +
+ )} +
+ + {mounts.data.length === 0 ? ( +
No mounts configured on this actor.
+ ) : ( +
+ +
+ )} +
+ + + +
+
+
+ ); +} diff --git a/packages/agentos/src/inspector-tabs/tabs/transcript.tsx b/packages/agentos/src/inspector-tabs/tabs/transcript.tsx index 34ff60c666..317d2112ab 100644 --- a/packages/agentos/src/inspector-tabs/tabs/transcript.tsx +++ b/packages/agentos/src/inspector-tabs/tabs/transcript.tsx @@ -1,139 +1,854 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import type { SessionStreamEntry } from "@rivet-dev/agentos-core"; -import { useEffect, useRef, useState } from "react"; -import { AgentOsEmpty, StatusDot } from "../common"; +import { useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActionErrorNote, + AgentOsEmpty, + AgentOsWordmark, + ChevronRight, + CopyButton, + IconButton, + PlusIcon, + relativeTime, + SearchIcon, + StatusDot, +} from "../common"; +import { isInspectorActionError } from "../lib/actor-client"; import { cn } from "../lib/cn"; import { useAgentOsActor } from "../lib/rivet"; -import { agentOsSource, mapSessionEvent } from "../lib/source"; -import type { TranscriptEvent } from "../lib/types"; +import { + agentOsSource, + cancelPrompt, + liveSessionIds, + mapSessionEvent, +} from "../lib/source"; +import type { + AgentCrashedPayload, + SessionStreamEntry, + TranscriptEvent, +} from "../lib/types"; import { ScrollArea } from "../ui/scroll-area"; +import { VmStatusBadges } from "../vm-status-badges"; import React from "react"; -function EventFrame({ label, meta, children }: { label: string; meta?: string; children: React.ReactNode }) { - return ( -
-
- {label} - {meta ? {meta} : null} -
- {children} -
- ); -} - +// Chat-style transcript rendering. Conversation content (user/assistant) gets +// bubbles; plumbing (usage updates, unknown ACP events) collapses to one-line +// chips with the raw JSON behind an expander so it never dominates the pane. function TranscriptEventView({ event }: { event: TranscriptEvent }) { switch (event.kind) { case "user": + return ( +
+
+ {event.text || "—"} +
+
+ ); case "assistant": return ( - -

{event.text || "—"}

-
+
+
+ {event.text || "—"} +
+
); case "thinking": return ( - -

{event.text || "—"}

-
+
+
+ {event.text || "—"} +
+
+ ); + case "tool": { + const hasBody = + event.input !== undefined || event.output !== undefined || !!event.locations?.length; + const summary = ( + <> + {event.tool} + {event.status ? ( + {event.status} + ) : null} + + ); + if (!hasBody) { + return ( +
{summary}
+ ); + } + return ( +
+ + + {summary} + +
+ {event.input !== undefined ? ( +
+
Input
+
+									{JSON.stringify(event.input, null, 2)}
+								
+
+ ) : null} + {event.output !== undefined ? ( +
+
Output
+
+									{event.output}
+								
+
+ ) : null} + {event.locations?.length ? ( +
+ {event.locations.join(" ")} +
+ ) : null} +
+
+ ); + } + case "plan": + return ( +
+
Plan
+
    + {event.entries.map((entry, i) => ( +
  • + + + {entry.content} + +
  • + ))} +
+
+ ); + case "notice": + return ( +
{event.text}
+ ); + case "permission": + return ( +
+ + {event.text} +
); - case "tool": + case "error": return ( - - {event.status ?? ""} - +
{event.text}
); default: return ( - -
+				
+ + + {event.label} + +
 						{JSON.stringify(event.json, null, 2)}
 					
- +
); } } +// Pins the transcript to the newest event — but only while the user is already +// at the bottom. Scrolling up to read history detaches the pin (tracked by an +// IntersectionObserver against the ScrollArea viewport); returning to the +// bottom re-attaches it. +function ScrollAnchor({ count }: { count: number }) { + const ref = useRef(null); + const stickRef = useRef(true); + useEffect(() => { + const el = ref.current; + const viewport = el?.closest("[data-radix-scroll-area-viewport]"); + if (!el || !viewport) return; + const observer = new IntersectionObserver( + ([entry]) => { + stickRef.current = entry.isIntersecting; + }, + { root: viewport, rootMargin: "0px 0px 96px 0px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + useEffect(() => { + if (stickRef.current) ref.current?.scrollIntoView({ block: "end" }); + }, [count]); + return
; +} + +// ── Render pipeline: raw event list → displayable rows ──────────────────── +// The ACP stream is chunked (one event per message fragment) and tool calls +// arrive as a `tool_call` plus N `tool_call_update`s. Coalesce so one message +// renders as one bubble and one tool call as one card that updates in place. +type KeyedEvent = TranscriptEvent & { key: string }; + +// Live rows carry a locally unique `liveKey` for React keys: ephemeral +// streaming deltas share one fractional `seq` (afterSequence + 0.5), so seq +// alone cannot key the live list. `seq` still does ordering + dedupe work. +type LiveTranscriptEvent = TranscriptEvent & { liveKey: number }; + +function coalesceTranscript(events: KeyedEvent[]): KeyedEvent[] { + const out: KeyedEvent[] = []; + const toolIndex = new Map(); + let planIndex: number | null = null; + for (const e of events) { + const last = out[out.length - 1]; + if ( + (e.kind === "user" || e.kind === "assistant" || e.kind === "thinking") && + last?.kind === e.kind + ) { + out[out.length - 1] = { ...last, text: last.text + e.text }; + continue; + } + if (e.kind === "tool" && e.toolCallId) { + const idx = toolIndex.get(e.toolCallId); + if (idx !== undefined) { + const prev = out[idx] as Extract; + out[idx] = { + ...prev, + // Updates often omit the title (the mapper then falls back to the + // id) — never overwrite a real title with the id. + tool: e.tool !== e.toolCallId ? e.tool : prev.tool, + status: e.status ?? prev.status, + input: e.input ?? prev.input, + output: e.output ?? prev.output, + locations: e.locations ?? prev.locations, + }; + continue; + } + toolIndex.set(e.toolCallId, out.length); + } + if (e.kind === "plan") { + // Plan updates are full snapshots: replace the existing card in place. + if (planIndex !== null) { + out[planIndex] = { ...out[planIndex], entries: e.entries } as KeyedEvent; + continue; + } + planIndex = out.length; + } + out.push(e); + } + return out; +} + +// Subtle three-dot pulse shown as the last row while a turn is in flight. +function TurnInFlight() { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); +} + +// Composer defaults persist in localStorage: they contain the API key the +// user pastes, which never leaves the browser except inside createSession's +// env (sent only to this actor). +const LS_AGENT_TYPE = "agentos-inspector:composer-agent-type"; +const LS_ENV_JSON = "agentos-inspector:composer-env"; +// Selected-session persistence across the per-tab iframe swap. +const transcriptStateKey = (actorId: string) => `agentos-inspector:transcript:${actorId}`; +const DEFAULT_ENV_JSON = JSON.stringify( + { + ANTHROPIC_API_KEY: "", + OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "anthropic/claude-haiku-4-5-20251001" }), + }, + null, + 2, +); + +// The composer's lazy create path and the sidebar's "+" button build sessions +// from the same localStorage-backed defaults, so both produce the same thing. +function newSessionDefaults(): { agentType: string; env: Record } { + const agentType = (localStorage.getItem(LS_AGENT_TYPE) ?? "opencode").trim() || "opencode"; + const raw = localStorage.getItem(LS_ENV_JSON) ?? DEFAULT_ENV_JSON; + let env: Record; + try { + env = JSON.parse(raw) as Record; + } catch (error) { + throw new Error(`Session env is not valid JSON: ${(error as Error).message}`); + } + return { agentType, env }; +} + +function Composer({ + actorId, + sessionId, + sessionStatus, + onSessionCreated, + onErrorEvent, + onBusyChange, +}: { + actorId: string; + sessionId: string | null; + sessionStatus?: string; + onSessionCreated: (sessionId: string) => void; + onErrorEvent: (text: string) => void; + onBusyChange?: (busy: boolean) => void; +}) { + const [draft, setDraft] = useState(""); + const [busy, setBusyState] = useState(false); + const setBusy = (b: boolean) => { + setBusyState(b); + onBusyChange?.(b); + }; + const [stopUnsupported, setStopUnsupported] = useState(false); + const [optionsOpen, setOptionsOpen] = useState(false); + const [agentType, setAgentType] = useState( + () => localStorage.getItem(LS_AGENT_TYPE) ?? "opencode", + ); + const [envJson, setEnvJson] = useState(() => localStorage.getItem(LS_ENV_JSON) ?? DEFAULT_ENV_JSON); + // The in-flight session survives re-renders; also used by Stop. + const activeSessionRef = useRef(null); + // Agent-type suggestions from the installed software (fetched only while the + // options panel is open). Free text stays authoritative: the datalist is a + // hint, and agent types are derived from package names heuristically. + const software = useQuery({ + ...agentOsSource.softwareQueryOptions(actorId), + enabled: optionsOpen, + }); + const agentTypeSuggestions = (software.data ?? []) + .filter((bundle) => bundle.name.endsWith("· agent")) + .map((bundle) => bundle.name.split(" · ")[0]?.split("/").pop() ?? "") + .filter(Boolean); + + const send = async () => { + const text = draft.trim(); + if (!text || busy) return; + setBusy(true); + setDraft(""); + try { + let sid = sessionId; + if (!sid) { + let env: Record; + try { + env = JSON.parse(envJson); + } catch (e) { + throw new Error(`Session env is not valid JSON: ${(e as Error).message}`); + } + sid = await agentOsSource.createSession(agentType.trim() || "opencode", { env }); + onSessionCreated(sid); + } + activeSessionRef.current = sid; + await agentOsSource.sendPrompt(sid, text); + } catch (error) { + let hint = isInspectorActionError(error) ? ` — ${error.hint}` : ""; + // Prompting a persisted-but-idle session (from an earlier VM boot) + // fails inside the runtime; the generic crash hint is misleading there. + if (sessionId && sessionStatus !== "running") { + hint = + " — this session is idle (not live in the current VM boot). Send without a selection to start a fresh session, or resume it from a client."; + } + onErrorEvent(`${error instanceof Error ? error.message : String(error)}${hint}`); + } finally { + activeSessionRef.current = null; + setBusy(false); + } + }; + + const stop = async () => { + const sid = activeSessionRef.current ?? sessionId; + if (!sid) return; + try { + const supported = await cancelPrompt(sid); + if (!supported) { + setStopUnsupported(true); + onErrorEvent("Stop is not supported by this runtime (no cancelPrompt action)."); + } + } catch (error) { + onErrorEvent(error instanceof Error ? error.message : String(error)); + } + }; + + return ( +
+
+ {optionsOpen && !sessionId ? ( +
+ +