Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next. | 1 | import { ChevronDown, ChevronRight, Cloud, HardDrive, Moon, Power, RotateCcw, Terminal } from "lucide-react"; |
| 2 | import { useState } from "react"; | |
| 3 | import { Link, useFetcher, useOutletContext } from "react-router"; | |
| 4 | ||
| 5 | import type { AgentComputerCommand, AgentComputerState, AgentComputerView, WorkspaceAgent } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import type { Route } from "./+types/computer"; | |
| 8 | import { agentsAction, answer } from "../../../components/agents/actions.server"; | |
| 9 | import { type ActionResult, Confirm } from "../../../components/agents/dialogs"; | |
| 10 | import { Quiet } from "../../../components/agents/parts"; | |
| 11 | import { TimeAgo } from "../../../components/ui"; | |
| 12 | import { Alert, AlertDescription } from "../../../components/ui/alert"; | |
| 13 | import { Badge, type BadgeTone } from "../../../components/ui/badge"; | |
| 14 | import { Button } from "../../../components/ui/button"; | |
| 15 | import { Card } from "../../../components/ui/card"; | |
| 16 | import { Hint } from "../../../components/ui/hint"; | |
| 17 | import { Progress } from "../../../components/ui/progress"; | |
| 18 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../components/ui/table"; | |
| 19 | import { page } from "../../../lib/meta"; | |
| 20 | import { useRefreshWhile } from "../../../lib/refresh"; | |
| 21 | import { workspaceAgents } from "../../../lib/services.server"; | |
| 22 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 23 | ||
| 24 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 25 | return page(args, { title: `Computer · @${params.handle} · ${params.owner} · g1t` }); | |
| 26 | } | |
| 27 | ||
| 28 | /** The agent's computer: its state, disk and recent commands, and whether the viewer may act on it. */ | |
| 29 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ view: AgentComputerView | null; error: string | null }> { | |
| 30 | const viewer = requireUser(context, request); | |
| 31 | if (!roleIn(viewer, params.owner)) throw new Response(null, { status: 404 }); | |
| 32 | const found = await workspaceAgents.computer(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer).catch(() => null); | |
| 33 | if (!found) return { view: null, error: "The agents service didn't answer. Reload in a moment." }; | |
| 34 | return found.ok ? { view: found.value, error: null } : { view: null, error: found.error.message }; | |
| 35 | } | |
| 36 | ||
| 37 | /** Wake it, put it to sleep, or reset it: owners, or the member whose personal agent it is. */ | |
| 38 | export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> { | |
| 39 | const { viewer, slug, form } = await agentsAction(request, context, params.owner); | |
| 40 | const intent = String(form.get("intent") ?? ""); | |
| 41 | const handle = params.handle.toLowerCase(); | |
| 42 | if (intent === "wake") return answer(intent, workspaceAgents.wakeComputer(slug, handle, viewer)); | |
| 43 | if (intent === "sleep") return answer(intent, workspaceAgents.sleepComputer(slug, handle, viewer)); | |
| 44 | if (intent === "reset") return answer(intent, workspaceAgents.resetComputer(slug, handle, viewer)); | |
| 45 | return { ok: false, intent, error: "Unknown request." }; | |
| 46 | } | |
| 47 | ||
| 48 | const STATES: Record<AgentComputerState, { label: string; tone: BadgeTone; moving: boolean }> = { | |
| 49 | asleep: { label: "Asleep", tone: "neutral", moving: false }, | |
| 50 | waking: { label: "Waking", tone: "info", moving: true }, | |
| 51 | awake: { label: "Awake", tone: "success", moving: false }, | |
| 52 | sleeping: { label: "Sleeping", tone: "info", moving: true }, | |
| 53 | }; | |
| 54 | ||
| 55 | /** Bytes as people read them: 1.2 GB, 340 MB, 12 KB. */ | |
| 56 | export function humanBytes(bytes: number): string { | |
| 57 | if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(bytes >= 10e9 ? 0 : 1)} GB`; | |
| 58 | if (bytes >= 1e6) return `${Math.round(bytes / 1e6)} MB`; | |
| 59 | if (bytes >= 1e3) return `${Math.round(bytes / 1e3)} KB`; | |
| 60 | return `${bytes} B`; | |
| 61 | } | |
| 62 | ||
| 63 | /** A duration in milliseconds as a short word: 1.2 s, 42 s, 5 min. */ | |
| 64 | export function took(ms: number): string { | |
| 65 | const seconds = ms / 1000; | |
| 66 | if (seconds < 10) return `${seconds.toFixed(1)} s`; | |
| 67 | if (seconds < 90) return `${Math.round(seconds)} s`; | |
| 68 | return `${Math.round(seconds / 60)} min`; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * The Computer tab: whether the agent's computer is awake, how full its | |
| 73 | * home is, where it runs, the buttons to wake, sleep and reset it, and the | |
| 74 | * commands it ran most recently with their output. | |
| 75 | */ | |
| 76 | export default function ComputerTab({ loaderData, params }: Route.ComponentProps) { | |
| 77 | const agent = useOutletContext<WorkspaceAgent>(); | |
| 78 | const { view, error } = loaderData; | |
| 79 | const fetcher = useFetcher<ActionResult>({ key: `computer-${agent.id}` }); | |
| 80 | const state = view?.status.state ?? "asleep"; | |
| 81 | useRefreshWhile(state === "waking" || state === "sleeping" || fetcher.state !== "idle"); | |
| 82 | const failed = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; | |
| 83 | if (!view) { | |
| 84 | return ( | |
| 85 | <Quiet title="This computer can't be shown right now" icon={<Terminal size={18} />}> | |
| 86 | {error ?? "The agents service didn't answer. Reload in a moment."} | |
| 87 | </Quiet> | |
| 88 | ); | |
| 89 | } | |
| 90 | const { status, commands, can_manage: canManage } = view; | |
| 91 | const shown = STATES[status.state]; | |
| 92 | const share = Math.min(100, Math.round((status.disk_used_bytes / status.disk_cap_bytes) * 100)); | |
| 93 | const busy = fetcher.state !== "idle"; | |
| 94 | const slug = params.owner; | |
| 95 | return ( | |
| 96 | <div className="space-y-8"> | |
| 97 | <section aria-labelledby="computer-state" className="max-w-3xl"> | |
| 98 | <h2 id="computer-state" className="sr-only"> | |
| 99 | Its computer | |
| 100 | </h2> | |
| 101 | <p className="text-base leading-relaxed text-fg"> | |
| 102 | {agent.display_name} has a computer of its own: a Linux machine with a home that stays between sessions. Sessions wake it to run commands and keep files; it sleeps | |
| 103 | after ten minutes idle and is saved as it was. Chat replies never use it. | |
| 104 | </p> | |
| 105 | </section> | |
| 106 | ||
| 107 | {failed && ( | |
| 108 | <Alert tone="danger" role="alert"> | |
| 109 | <AlertDescription>{failed}</AlertDescription> | |
| 110 | </Alert> | |
| 111 | )} | |
| 112 | {status.problem && ( | |
| 113 | <Alert tone="warn"> | |
| 114 | <AlertDescription>{status.problem}</AlertDescription> | |
| 115 | </Alert> | |
| 116 | )} | |
| 117 | {!status.disk_attached && ( | |
| 118 | <Alert tone="info"> | |
| 119 | <AlertDescription>This computer's disk isn't attached yet: it works, but forgets its home when it sleeps.</AlertDescription> | |
| 120 | </Alert> | |
| 121 | )} | |
| 122 | {status.delete_after && ( | |
| 123 | <Alert tone="warn"> | |
| 124 | <AlertDescription> | |
| 125 | Its disk will be deleted <TimeAgo at={status.delete_after} />, since the agent was archived. | |
| 126 | </AlertDescription> | |
| 127 | </Alert> | |
| 128 | )} | |
| 129 | ||
| 130 | <Card className="p-5 sm:p-6"> | |
| 131 | <div className="flex flex-wrap items-center gap-3"> | |
| 132 | <Badge tone={shown.tone} size="md" className={shown.moving ? "animate-pulse motion-reduce:animate-none" : undefined}> | |
| 133 | <Power size={11} aria-hidden /> | |
| 134 | {shown.label} | |
| 135 | </Badge> | |
| 136 | <span className="text-sm text-muted"> | |
| 137 | since <TimeAgo at={status.since} /> | |
| 138 | </span> | |
| 139 | </div> | |
| 140 | <dl className="mt-5 grid gap-x-6 gap-y-4 text-sm sm:grid-cols-2 lg:grid-cols-4"> | |
| 141 | <div className="min-w-0 sm:col-span-2"> | |
| 142 | <dt className="flex items-center gap-1.5 text-xs text-faint"> | |
| 143 | <HardDrive size={12} aria-hidden /> | |
| 144 | Disk | |
| 145 | </dt> | |
| 146 | <dd className="mt-1.5"> | |
| 147 | <Progress value={share} aria-label="Disk used" indicatorClassName={share >= 90 ? "bg-danger" : share >= 75 ? "bg-warn" : undefined} /> | |
| 148 | <p className="mt-1.5 tabular-nums text-fg-soft"> | |
| 149 | {humanBytes(status.disk_used_bytes)} of {humanBytes(status.disk_cap_bytes)} <span className="text-faint">· included, no disk charge</span> | |
| 150 | </p> | |
| 151 | </dd> | |
| 152 | </div> | |
| 153 | <div className="min-w-0"> | |
| 154 | <dt className="flex items-center gap-1.5 text-xs text-faint"> | |
| 155 | <Cloud size={12} aria-hidden /> | |
| 156 | Where it runs | |
| 157 | </dt> | |
| 158 | <dd className="mt-1.5 flex flex-wrap items-center gap-2"> | |
| 159 | g1t cloud | |
| 160 | <Hint label="Running an agent's computer on one of your own runners is coming."> | |
| 161 | <Badge tone="neutral">Pin to your own runner · coming</Badge> | |
| 162 | </Hint> | |
| 163 | </dd> | |
| 164 | </div> | |
| 165 | <div className="min-w-0"> | |
| 166 | <dt className="text-xs text-faint">Saved home</dt> | |
| 167 | <dd className="mt-1.5 text-fg-soft"> | |
| 168 | {status.snapshot_at ? ( | |
| 169 | <> | |
| 170 | {humanBytes(status.snapshot_bytes ?? 0)} · <TimeAgo at={status.snapshot_at} /> | |
| 171 | </> | |
| 172 | ) : ( | |
| 173 | "Nothing saved yet" | |
| 174 | )} | |
| 175 | </dd> | |
| 176 | </div> | |
| 177 | <div className="min-w-0"> | |
| 178 | <dt className="text-xs text-faint">Last awake</dt> | |
| 179 | <dd className="mt-1.5 text-fg-soft">{status.last_woke_at ? <TimeAgo at={status.last_woke_at} /> : "Never"}</dd> | |
| 180 | </div> | |
| 181 | <div className="min-w-0"> | |
| 182 | <dt className="text-xs text-faint">Last slept</dt> | |
| 183 | <dd className="mt-1.5 text-fg-soft">{status.last_slept_at ? <TimeAgo at={status.last_slept_at} /> : "—"}</dd> | |
| 184 | </div> | |
| 185 | </dl> | |
| 186 | <div className="mt-5 flex flex-wrap items-center gap-2 border-t border-line pt-4"> | |
| 187 | {canManage ? ( | |
| 188 | <> | |
| 189 | <fetcher.Form method="post"> | |
| 190 | <input type="hidden" name="intent" value="wake" /> | |
| 191 | <Button type="submit" variant="accent" disabled={busy || status.state !== "asleep"}> | |
| 192 | <Power size={15} /> | |
| 193 | {busy && fetcher.formData?.get("intent") === "wake" ? "Waking…" : "Wake"} | |
| 194 | </Button> | |
| 195 | </fetcher.Form> | |
| 196 | <fetcher.Form method="post"> | |
| 197 | <input type="hidden" name="intent" value="sleep" /> | |
| 198 | <Button type="submit" variant="outline" disabled={busy || status.state !== "awake"}> | |
| 199 | <Moon size={15} /> | |
| 200 | {busy && fetcher.formData?.get("intent") === "sleep" ? "Saving…" : "Put to sleep"} | |
| 201 | </Button> | |
| 202 | </fetcher.Form> | |
| 203 | <Confirm | |
| 204 | title="Reset this computer?" | |
| 205 | confirm="Reset computer" | |
| 206 | fields={{ intent: "reset" }} | |
| 207 | fetcherKey={`computer-reset-${agent.id}`} | |
| 208 | trigger={ | |
| 209 | <Button type="button" variant="destructive"> | |
| 210 | <RotateCcw size={15} /> | |
| 211 | Reset computer | |
| 212 | </Button> | |
| 213 | } | |
| 214 | > | |
| 215 | This wipes {agent.display_name}'s home: its clones, installed tools, notes and the commands listed here. Its memory and artifacts are kept. If it is awake it is | |
| 216 | stopped first. | |
| 217 | </Confirm> | |
| 218 | </> | |
| 219 | ) : ( | |
| 220 | <p className="text-sm text-muted">{agent.scope === "personal" ? "Its member" : "The workspace's owners"} wake, sleep and reset it. Sessions wake it by themselves.</p> | |
| 221 | )} | |
| 222 | <span className="ml-auto text-xs text-faint">Awake time is sandbox time on the workspace's usage, under this agent.</span> | |
| 223 | </div> | |
| 224 | </Card> | |
| 225 | ||
| 226 | <section aria-labelledby="commands" className="space-y-3"> | |
| 227 | <div> | |
| 228 | <h2 id="commands" className="text-sm font-semibold"> | |
| 229 | Recent commands | |
| 230 | </h2> | |
| 231 | <p className="text-sm text-muted"> | |
| 232 | {commands.length === 0 ? "Up to 50 of the commands it runs are kept here, newest first." : `The last ${commands.length === 1 ? "command" : `${commands.length} commands`} it ran, newest first.`} Each | |
| 233 | session's page shows its own. | |
| 234 | </p> | |
| 235 | </div> | |
| 236 | {commands.length === 0 ? ( | |
| 237 | <Quiet title="Nothing run yet" icon={<Terminal size={18} />}> | |
| 238 | When a session runs a command on this computer it is listed here with its output. | |
| 239 | </Quiet> | |
| 240 | ) : ( | |
| 241 | <CommandsTable slug={slug} handle={agent.handle} commands={commands} /> | |
| 242 | )} | |
| 243 | </section> | |
| 244 | </div> | |
| 245 | ); | |
| 246 | } | |
| 247 | ||
| 248 | /** The commands, one row each, with the output folded under the row. */ | |
| 249 | function CommandsTable({ slug, handle, commands }: { slug: string; handle: string; commands: AgentComputerCommand[] }) { | |
| 250 | const [open, setOpen] = useState<Set<string>>(() => new Set()); | |
| 251 | const toggle = (id: string) => | |
| 252 | setOpen((before) => { | |
| 253 | const next = new Set(before); | |
| 254 | if (next.has(id)) next.delete(id); | |
| 255 | else next.add(id); | |
| 256 | return next; | |
| 257 | }); | |
| 258 | return ( | |
| 259 | <Table> | |
| 260 | <TableHeader> | |
| 261 | <TableRow> | |
| 262 | <TableHead className="w-28">When</TableHead> | |
| 263 | <TableHead className="w-32">Session</TableHead> | |
| 264 | <TableHead>Command</TableHead> | |
| 265 | <TableHead className="w-20 text-right">Exit</TableHead> | |
| 266 | <TableHead className="w-20 text-right">Took</TableHead> | |
| 267 | <TableHead className="w-24" /> | |
| 268 | </TableRow> | |
| 269 | </TableHeader> | |
| 270 | <TableBody> | |
| 271 | {commands.map((command) => { | |
| 272 | const shown = open.has(command.id); | |
| 273 | return [ | |
| 274 | <TableRow key={command.id}> | |
| 275 | <TableCell className="text-muted"> | |
| 276 | <TimeAgo at={command.started_at} /> | |
| 277 | </TableCell> | |
| 278 | <TableCell> | |
| 279 | {command.session_id ? ( | |
| 280 | <Link to={`/${slug}/-/agents/${handle}/sessions/${command.session_id}`} className="font-mono text-xs hover:underline"> | |
| 281 | {command.session_id.slice(0, 12)} | |
| 282 | </Link> | |
| 283 | ) : ( | |
| 284 | <span className="text-faint">—</span> | |
| 285 | )} | |
| 286 | </TableCell> | |
| 287 | <TableCell className="max-w-0"> | |
| 288 | <span className="block truncate font-mono text-xs">{command.cmd.split("\n")[0]}</span> | |
| 289 | </TableCell> | |
| 290 | <TableCell className="text-right"> | |
| 291 | <Badge tone={command.exit_code === 0 ? "success" : "danger"}>{command.timed_out ? "timed out" : command.exit_code}</Badge> | |
| 292 | </TableCell> | |
| 293 | <TableCell className="text-right tabular-nums text-muted">{took(command.duration_ms)}</TableCell> | |
| 294 | <TableCell className="text-right"> | |
| 295 | <Button type="button" variant="ghost" size="xs" aria-expanded={shown} aria-controls={`output-${command.id}`} onClick={() => toggle(command.id)}> | |
| 296 | {shown ? <ChevronDown size={13} /> : <ChevronRight size={13} />} | |
| 297 | Output | |
| 298 | </Button> | |
| 299 | </TableCell> | |
| 300 | </TableRow>, | |
| 301 | shown ? ( | |
| 302 | <TableRow key={`${command.id}-output`} id={`output-${command.id}`}> | |
| 303 | <TableCell colSpan={6} className="bg-bg"> | |
| 304 | <p className="mb-1.5 font-mono text-xs text-faint"> | |
| 305 | $ {command.cmd} <span className="text-faint">· in {command.cwd}</span> | |
| 306 | </p> | |
| 307 | <pre className="max-h-96 overflow-auto rounded-md border border-line bg-surface px-3 py-2 font-mono text-xs whitespace-pre-wrap text-muted">{command.output || "(no output)"}</pre> | |
| 308 | {command.truncated && <p className="mt-1.5 text-xs text-faint">The output was cut.</p>} | |
| 309 | </TableCell> | |
| 310 | </TableRow> | |
| 311 | ) : null, | |
| 312 | ]; | |
| 313 | })} | |
| 314 | </TableBody> | |
| 315 | </Table> | |
| 316 | ); | |
| 317 | } |