Skip to content

Commit 76525de

Browse files
committed
feat(inspector): custom actor inspector tab UI (Test/Mounts/Software/Processes/Filesystem)
The shared Vite/React inspector-tabs source that fetches live actor actions and renders the custom tabs.
1 parent 24c11e6 commit 76525de

22 files changed

Lines changed: 1750 additions & 7 deletions

‎packages/agentos/src/actor.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
*/
1212

1313
import { readFileSync } from "node:fs";
14-
import { join } from "node:path";
14+
import { dirname, join } from "node:path";
15+
import { fileURLToPath } from "node:url";
1516
import common from "@agentos-software/common";
1617
import {
1718
OPT_AGENTOS_BIN,
@@ -399,6 +400,16 @@ function buildNativeFactoryBuilder<TConnParams>(
399400
// spawns the bundled binary rather than relying on `agentos-sidecar`
400401
// being on PATH.
401402
sidecarPath: getSidecarPath(),
403+
// Custom inspector tabs. The native-plugin path bypasses the normal
404+
// actor-config assembly (`buildActorConfig`/`inspectorTabs`), so the
405+
// tabs MUST ride on the plugin options: the Rust NAPI binding
406+
// `from_native_plugin` forwards `inspectorTabs` into the actor config so
407+
// the dashboard serves `/inspector/custom-tabs/<id>/` and advertises them
408+
// in `tab-config`. (Setting `actor({ inspector })` alone does nothing for
409+
// native-plugin actors.)
410+
inspectorTabs: AGENTOS_INSPECTOR_CONFIG.tabs,
411+
} as NapiNativePluginOptions & {
412+
inspectorTabs: typeof AGENTOS_INSPECTOR_CONFIG.tabs;
402413
};
403414
return runtime.createNativePluginFactory(options);
404415
};
@@ -465,6 +476,34 @@ export const DEFAULT_AGENTOS_ACTOR_OPTIONS = {
465476
maxQueueMessageSize: ACTOR_NEVER_HIT_MESSAGE_BYTES,
466477
} as const;
467478

479+
// Absolute path to the built inspector-tabs app (the shared Vite bundle). All
480+
// custom tabs share this one `source` dir; the app routes on the
481+
// `/inspector/custom-tabs/<id>/` URL segment. Resolves from both `src/` (tsx dev
482+
// / the demo) and the published `dist/`, since `assets/` sits at the package
483+
// root in both layouts.
484+
const INSPECTOR_TABS_ASSET_DIR = join(
485+
dirname(fileURLToPath(import.meta.url)),
486+
"..",
487+
"assets",
488+
"inspector-tabs-app",
489+
);
490+
491+
// Custom inspector tabs shipped by agent-os. Ids MUST match the `TABS` registry
492+
// in `src/inspector-tabs/main.tsx`. The built-in rivetkit tabs are hidden so the
493+
// dashboard shows only the agent-os tabs.
494+
const AGENTOS_INSPECTOR_CONFIG = {
495+
tabs: [
496+
{ id: "transcript", label: "Transcript", source: INSPECTOR_TABS_ASSET_DIR, icon: "comments" },
497+
{ id: "filesystem", label: "Filesystem", source: INSPECTOR_TABS_ASSET_DIR, icon: "folder-tree" },
498+
{ id: "processes", label: "Processes", source: INSPECTOR_TABS_ASSET_DIR, icon: "microchip" },
499+
{ id: "software", label: "Software", source: INSPECTOR_TABS_ASSET_DIR, icon: "box-archive" },
500+
{ id: "mounts", label: "Mounts", source: INSPECTOR_TABS_ASSET_DIR, icon: "hard-drive" },
501+
...(["workflow", "database", "state", "queue", "connections", "console"].map(
502+
(id) => ({ id, hidden: true as const }),
503+
)),
504+
],
505+
};
506+
468507
export function agentOs<TConnParams = undefined>(
469508
config: AgentOsActorConfigInput<TConnParams>,
470509
): AgentOsActorDefinition<TConnParams> {
@@ -487,6 +526,10 @@ export function agentOs<TConnParams = undefined>(
487526
const definition = actor({
488527
actions: {},
489528
options: actorOptions,
529+
// Register the custom agent-os inspector tabs (and hide the built-in
530+
// rivetkit tabs) so the dashboard renders the agent-os UI. Without this
531+
// the shipped tab assets are never surfaced.
532+
inspector: AGENTOS_INSPECTOR_CONFIG,
490533
} as Parameters<
491534
typeof actor
492535
>[0]) as unknown as AgentOsActorDefinition<TConnParams>;
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type { ReactNode } from "react";
2+
import { cn } from "./lib/cn";
3+
import React, { useState } from "react";
4+
5+
/** Centered empty/placeholder state filling the tab body. */
6+
export function AgentOsEmpty({ children }: { children: ReactNode }) {
7+
return (
8+
<div className="flex h-full flex-1 items-center justify-center p-8 text-center text-sm text-muted-foreground">
9+
{children}
10+
</div>
11+
);
12+
}
13+
14+
export type DotColor = "green" | "amber" | "red" | "muted";
15+
const DOT_CLASS: Record<DotColor, string> = {
16+
green: "text-green-500",
17+
amber: "text-amber-500",
18+
red: "text-red-500",
19+
muted: "text-muted-foreground/50",
20+
};
21+
22+
export function StatusDot({ color, className }: { color: DotColor; className?: string }) {
23+
return (
24+
<svg
25+
viewBox="0 0 8 8"
26+
className={cn("size-2 shrink-0 fill-current", DOT_CLASS[color], className)}
27+
aria-hidden="true"
28+
>
29+
<circle cx="4" cy="4" r="4" />
30+
</svg>
31+
);
32+
}
33+
34+
/** Inline chevron (replaces @rivet-gg/icons faChevronRight). */
35+
export function ChevronRight({ className }: { className?: string }) {
36+
return (
37+
<svg
38+
className={className}
39+
viewBox="0 0 16 16"
40+
fill="none"
41+
stroke="currentColor"
42+
strokeWidth="2"
43+
strokeLinecap="round"
44+
strokeLinejoin="round"
45+
aria-hidden="true"
46+
>
47+
<path d="M6 4l4 4-4 4" />
48+
</svg>
49+
);
50+
}
51+
52+
/** Clipboard + check glyphs for the copy button. */
53+
function CopyIcon({ className }: { className?: string }) {
54+
return (
55+
<svg
56+
className={className}
57+
viewBox="0 0 16 16"
58+
fill="none"
59+
stroke="currentColor"
60+
strokeWidth="1.5"
61+
strokeLinecap="round"
62+
strokeLinejoin="round"
63+
aria-hidden="true"
64+
>
65+
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
66+
<path d="M10.5 5.5V4A1.5 1.5 0 0 0 9 2.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5" />
67+
</svg>
68+
);
69+
}
70+
function CheckIcon({ className }: { className?: string }) {
71+
return (
72+
<svg
73+
className={className}
74+
viewBox="0 0 16 16"
75+
fill="none"
76+
stroke="currentColor"
77+
strokeWidth="2"
78+
strokeLinecap="round"
79+
strokeLinejoin="round"
80+
aria-hidden="true"
81+
>
82+
<path d="M3 8.5l3.5 3.5L13 4.5" />
83+
</svg>
84+
);
85+
}
86+
87+
/** Small, subtle copy-to-clipboard button. Shows a check briefly on success. */
88+
export function CopyButton({ value, className }: { value: string; className?: string }) {
89+
const [copied, setCopied] = useState(false);
90+
return (
91+
<button
92+
type="button"
93+
aria-label={copied ? "Copied" : "Copy"}
94+
title={copied ? "Copied" : "Copy"}
95+
onClick={(e) => {
96+
e.stopPropagation();
97+
navigator.clipboard
98+
?.writeText(value)
99+
.then(() => {
100+
setCopied(true);
101+
setTimeout(() => setCopied(false), 1200);
102+
})
103+
.catch(() => {});
104+
}}
105+
className={cn(
106+
"inline-flex size-5 items-center justify-center rounded text-muted-foreground/40 transition-colors hover:bg-muted hover:text-foreground",
107+
copied && "text-green-500",
108+
className,
109+
)}
110+
>
111+
{copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3.5" />}
112+
</button>
113+
);
114+
}
115+
116+
/** Inline folder/file glyphs for the filesystem tree. */
117+
export function FileGlyph({ dir, className }: { dir: boolean; className?: string }) {
118+
return (
119+
<svg
120+
className={className}
121+
viewBox="0 0 16 16"
122+
fill="none"
123+
stroke="currentColor"
124+
strokeWidth="1.5"
125+
strokeLinecap="round"
126+
strokeLinejoin="round"
127+
aria-hidden="true"
128+
>
129+
{dir ? (
130+
<path d="M1.5 4.5A1 1 0 0 1 2.5 3.5h3l1.5 1.5h5.5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1z" />
131+
) : (
132+
<>
133+
<path d="M4 1.5h5l3 3v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1z" />
134+
<path d="M9 1.5v3h3" />
135+
</>
136+
)}
137+
</svg>
138+
);
139+
}
140+
141+
/** Human-readable byte size; "—" for nullish. */
142+
export function formatBytes(bytes?: number | null): string {
143+
if (bytes == null) return "—";
144+
if (bytes < 1024) return `${bytes} B`;
145+
const units = ["KiB", "MiB", "GiB", "TiB"];
146+
let value = bytes / 1024;
147+
let unit = 0;
148+
while (value >= 1024 && unit < units.length - 1) {
149+
value /= 1024;
150+
unit += 1;
151+
}
152+
const f = value >= 100 ? value.toFixed(0) : value.toFixed(1);
153+
return `${f.replace(/\.0$/, "")} ${units[unit]}`;
154+
}
155+
156+
/** Compact relative time ("5s ago", "3m ago") from an epoch-ms value. */
157+
export function relativeTime(ms?: number | null): string {
158+
if (ms == null) return "—";
159+
const diff = Date.now() - ms;
160+
if (diff < 0) return "just now";
161+
const s = Math.floor(diff / 1000);
162+
if (s < 60) return `${s}s ago`;
163+
const m = Math.floor(s / 60);
164+
if (m < 60) return `${m}m ago`;
165+
const h = Math.floor(m / 60);
166+
if (h < 24) return `${h}h ago`;
167+
return `${Math.floor(h / 24)}d ago`;
168+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>agentOS inspector</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="./main.tsx"></script>
11+
</body>
12+
</html>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Same-origin agentOS action client for inspector-tab iframes.
2+
//
3+
// The dashboard serves each custom tab at `/gateway/<actorId>@<authToken>/
4+
// inspector/custom-tabs/<id>/` and posts an `init` message with the actorId +
5+
// authToken. We call actor actions with the stateless JSON encoding — a plain
6+
// `fetch`, no rivetkit client, no websocket (proven by the vanilla Mounts tab).
7+
8+
import { isMockMode, mockCallAction } from "./mock";
9+
10+
interface Auth {
11+
actorId: string;
12+
authToken: string;
13+
}
14+
15+
let auth: Auth | undefined;
16+
17+
export function setAuth(next: Auth): void {
18+
auth = next;
19+
}
20+
21+
export function getAuth(): Auth | undefined {
22+
return auth;
23+
}
24+
25+
/** The tab id this iframe is rendering, parsed from its own gateway URL. */
26+
export function tabIdFromUrl(): string | undefined {
27+
const parts = window.location.pathname.split("/").filter(Boolean);
28+
const idx = parts.indexOf("custom-tabs");
29+
return idx >= 0 ? parts[idx + 1] : undefined;
30+
}
31+
32+
export interface ActionError {
33+
group?: string;
34+
code?: string;
35+
message?: string;
36+
}
37+
38+
/** Invoke an actor action over the same-origin gateway (JSON encoding).
39+
*
40+
* `timeoutMs` aborts the request (via AbortController) on expiry — important
41+
* because some VM actions (e.g. `stat` on /proc, /sys) hang server-side, and an
42+
* un-aborted hung fetch keeps a browser connection slot open. Enough of those
43+
* exhaust the ~6-connection pool and starve every other request. Aborting frees
44+
* the slot.
45+
*/
46+
export async function callAction<T = unknown>(
47+
name: string,
48+
args: unknown[] = [],
49+
opts: { timeoutMs?: number } = {},
50+
): Promise<T> {
51+
// Dummy/offline mode: serve every action from in-memory fixtures, no actor.
52+
if (isMockMode()) return mockCallAction<T>(name, args);
53+
if (!auth) throw new Error("not initialized: missing actorId/authToken");
54+
const seg = `${encodeURIComponent(auth.actorId)}@${encodeURIComponent(auth.authToken)}`;
55+
const url = `/gateway/${seg}/action/${encodeURIComponent(name)}`;
56+
const ctrl = new AbortController();
57+
const timer = opts.timeoutMs
58+
? setTimeout(() => ctrl.abort(), opts.timeoutMs)
59+
: undefined;
60+
let res: Response;
61+
try {
62+
res = await fetch(url, {
63+
method: "POST",
64+
headers: { "x-rivet-encoding": "json" },
65+
body: JSON.stringify({ args }),
66+
signal: ctrl.signal,
67+
});
68+
} finally {
69+
if (timer) clearTimeout(timer);
70+
}
71+
const text = await res.text();
72+
let body: { output?: T } & ActionError;
73+
try {
74+
body = text ? JSON.parse(text) : {};
75+
} catch {
76+
throw new Error(`non-JSON response (HTTP ${res.status}): ${text.slice(0, 200)}`);
77+
}
78+
if (!res.ok) {
79+
const label = body.code ? `${body.group}/${body.code}` : `HTTP ${res.status}`;
80+
throw new Error(`${label}: ${body.message ?? text}`);
81+
}
82+
return body.output as T;
83+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Vendored from the rivet design system (@/components/lib/utils.ts → `cn`).
2+
// Self-contained: only clsx + tailwind-merge.
3+
import { type ClassValue, clsx } from "clsx";
4+
import { twMerge } from "tailwind-merge";
5+
6+
export function cn(...inputs: ClassValue[]) {
7+
return twMerge(clsx(inputs));
8+
}

0 commit comments

Comments
 (0)