Add the recorded talk, a player, and the Supported Systems badge
The talk exists as audio now: a synthesised narration, four and a half minutes, on a /talk/ page with the full transcript underneath. The transcript is the authoritative version, and the audio is preload="none" so the 2.5 MB file is only fetched if somebody presses play. A second player sits on the homepage, the way spicebook surfaces its pitch. The badge follows the homestar.ink pattern: a small webring-style button in the footer that opens a short modal about the studio. It is a Starlight footer override, so it appears site-wide rather than only on the landing page, and it closes on Escape or backdrop click with focus returned to the badge. Talk script and narration text are kept in drafts/ for reuse.
This commit is contained in:
parent
ca105401c4
commit
235ffc955c
@ -41,6 +41,11 @@ export default defineConfig({
|
||||
favicon: "/favicon.svg",
|
||||
customCss: ["./src/styles/theme.css"],
|
||||
|
||||
// Keep the default footer and hang the studio badge under it.
|
||||
components: {
|
||||
Footer: "./src/components/Footer.astro",
|
||||
},
|
||||
|
||||
social: [
|
||||
{
|
||||
icon: "seti:git",
|
||||
@ -56,7 +61,10 @@ export default defineConfig({
|
||||
sidebar: [
|
||||
{
|
||||
label: "Start here",
|
||||
items: [{ label: "What is mcqemu?", slug: "overview" }],
|
||||
items: [
|
||||
{ label: "What is mcqemu?", slug: "overview" },
|
||||
{ label: "The talk", slug: "talk" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tutorial",
|
||||
|
||||
BIN
docs-site/public/audio/mcqemu-lightning-talk.mp3
Normal file
BIN
docs-site/public/audio/mcqemu-lightning-talk.mp3
Normal file
Binary file not shown.
66
docs-site/public/supported-systems-logo.svg
Normal file
66
docs-site/public/supported-systems-logo.svg
Normal file
@ -0,0 +1,66 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 75" height="100%" width="100%">
|
||||
<!-- Gradient Definitions -->
|
||||
<defs>
|
||||
<linearGradient id="gradient1" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#60a5fa"></stop>
|
||||
<stop offset="50%" stop-color="#3b82f6"></stop>
|
||||
<stop offset="100%" stop-color="#2563eb"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient2" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#93c5fd"></stop>
|
||||
<stop offset="50%" stop-color="#60a5fa"></stop>
|
||||
<stop offset="100%" stop-color="#3b82f6"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="flowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#2563eb"></stop>
|
||||
<stop offset="50%" stop-color="#60a5fa"></stop>
|
||||
<stop offset="100%" stop-color="#2563eb"></stop>
|
||||
</linearGradient>
|
||||
<pattern id="circuitPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
|
||||
<rect width="12" height="45" fill="url(#gradient1)"></rect>
|
||||
<path d="M2,5 h8 M2,5 v5 M10,5 v10 M5,15 h5 M5,15 v10 M3,25 h7 M7,25 v10 M3,35 h4" stroke="#dbeafe" stroke-width="0.5" fill="none" opacity="0.7"></path>
|
||||
<circle cx="2" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
<circle cx="10" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
<circle cx="5" cy="15" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
<circle cx="3" cy="25" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
<circle cx="7" cy="35" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
</pattern>
|
||||
<pattern id="binaryPattern" patternUnits="userSpaceOnUse" width="12" height="35" patternTransform="scale(1)">
|
||||
<rect width="12" height="35" fill="#2563eb"></rect>
|
||||
<text x="3" y="8" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
|
||||
<text x="3" y="14" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">01</text>
|
||||
<text x="3" y="20" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">11</text>
|
||||
<text x="3" y="26" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">00</text>
|
||||
<text x="3" y="32" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
|
||||
</pattern>
|
||||
<pattern id="punchCardPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
|
||||
<rect width="12" height="45" fill="#3b82f6"></rect>
|
||||
<path d="M0,5 h12 M0,10 h12 M0,15 h12 M0,20 h12 M0,25 h12 M0,30 h12 M0,35 h12 M0,40 h12" stroke="#93c5fd" stroke-width="0.2" fill="none"></path>
|
||||
<circle cx="3" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="9" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="6" cy="12" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="3" cy="17" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="9" cy="22" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="6" cy="27" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="3" cy="32" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="9" cy="37" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
</pattern>
|
||||
</defs>
|
||||
<!-- Flow lines behind bars -->
|
||||
<g opacity="0.3">
|
||||
<path d="M6,50 C20,40 40,55 48,35 C56,50 75,30 90,55" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
|
||||
<path d="M6,60 C30,50 50,40 70,55 C80,45 90,60 90,60" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
|
||||
</g>
|
||||
<!-- Bar chart graphic - the "towers" -->
|
||||
<g>
|
||||
<rect x="0" y="45" width="12" height="25" rx="1" ry="1" fill="url(#binaryPattern)"></rect>
|
||||
<rect x="14" y="35" width="12" height="35" rx="1" ry="1" fill="#2563eb"></rect>
|
||||
<rect x="28" y="25" width="12" height="45" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
|
||||
<rect x="42" y="20" width="12" height="50" rx="1" ry="1" fill="url(#gradient2)"></rect>
|
||||
<rect x="56" y="25" width="12" height="45" rx="1" ry="1" fill="url(#punchCardPattern)"></rect>
|
||||
<rect x="70" y="35" width="12" height="35" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
|
||||
<rect x="84" y="45" width="12" height="25" rx="1" ry="1" fill="#2563eb"></rect>
|
||||
<!-- Connecting glow -->
|
||||
<path d="M12,55 L14,55 M26,45 L28,45 M40,40 L42,40 M54,40 L56,40 M82,55 L84,55" stroke="#bfdbfe" stroke-width="0.8" stroke-opacity="0.6"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
9
docs-site/src/components/Footer.astro
Normal file
9
docs-site/src/components/Footer.astro
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
// Starlight footer override: keep everything the default footer does
|
||||
// (pagination, edit link, last-updated) and add the studio badge underneath.
|
||||
import Default from "@astrojs/starlight/components/Footer.astro";
|
||||
import JointBadge from "./JointBadge.astro";
|
||||
---
|
||||
|
||||
<Default><slot /></Default>
|
||||
<JointBadge />
|
||||
70
docs-site/src/components/JointBadge.astro
Normal file
70
docs-site/src/components/JointBadge.astro
Normal file
@ -0,0 +1,70 @@
|
||||
---
|
||||
// "A Supported Systems Joint" badge, in the spirit of a 90s webring button.
|
||||
// Same idea as the one on homestar.ink: a small badge that opens a short
|
||||
// modal explaining who built the thing.
|
||||
---
|
||||
|
||||
<div class="joint">
|
||||
<button
|
||||
type="button"
|
||||
class="joint__badge"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="joint-modal"
|
||||
title="A Supported Systems joint"
|
||||
>
|
||||
<img src="/supported-systems-logo.svg" alt="" width="28" height="21" />
|
||||
<span>A Supported Systems Joint</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="joint__backdrop" id="joint-modal" role="dialog" aria-modal="true" aria-labelledby="joint-modal-title" hidden>
|
||||
<div class="joint__card">
|
||||
<button type="button" class="joint__close" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path
|
||||
d="M18 6 6 18M6 6l12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img class="joint__mark" src="/supported-systems-logo.svg" alt="Supported Systems" width="72" height="54" />
|
||||
<h2 class="joint__title" id="joint-modal-title">A Supported Systems Joint</h2>
|
||||
<p class="joint__text">
|
||||
mcqemu is built and maintained by Supported Systems, a small studio that
|
||||
writes software for people who have to keep it running afterwards.
|
||||
</p>
|
||||
<a class="joint__link" href="https://supported.systems" target="_blank" rel="noopener noreferrer">
|
||||
Visit supported.systems
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const modal = document.getElementById("joint-modal");
|
||||
const openers = document.querySelectorAll<HTMLButtonElement>(".joint__badge");
|
||||
const closer = modal?.querySelector<HTMLButtonElement>(".joint__close");
|
||||
let lastFocused: HTMLElement | null = null;
|
||||
|
||||
const open = (trigger: HTMLElement) => {
|
||||
lastFocused = trigger;
|
||||
modal?.removeAttribute("hidden");
|
||||
closer?.focus();
|
||||
};
|
||||
const close = () => {
|
||||
modal?.setAttribute("hidden", "");
|
||||
lastFocused?.focus();
|
||||
};
|
||||
|
||||
openers.forEach((button) => button.addEventListener("click", () => open(button)));
|
||||
closer?.addEventListener("click", close);
|
||||
modal?.addEventListener("click", (event) => {
|
||||
if (event.target === modal) close();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !modal?.hasAttribute("hidden")) close();
|
||||
});
|
||||
</script>
|
||||
84
docs-site/src/components/TalkPlayer.astro
Normal file
84
docs-site/src/components/TalkPlayer.astro
Normal file
@ -0,0 +1,84 @@
|
||||
---
|
||||
// A small audio player for the recorded talk. Vanilla JS, no framework, and
|
||||
// preload="none" so the 2.5 MB file is only fetched if someone presses play.
|
||||
interface Props {
|
||||
src: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
const { src, title = "Listen to the talk", subtitle } = Astro.props;
|
||||
---
|
||||
|
||||
<figure class="talk-player">
|
||||
<button type="button" class="talk-player__button" aria-label="Play the talk">
|
||||
<svg class="talk-player__icon talk-player__icon--play" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5v14l11-7z" fill="currentColor" />
|
||||
</svg>
|
||||
<svg
|
||||
class="talk-player__icon talk-player__icon--pause"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
hidden
|
||||
>
|
||||
<path d="M7 5h3v14H7zM14 5h3v14h-3z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="talk-player__body">
|
||||
<p class="talk-player__title">{title}</p>
|
||||
{subtitle && <p class="talk-player__subtitle">{subtitle}</p>}
|
||||
<div class="talk-player__track" role="presentation">
|
||||
<div class="talk-player__progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<time class="talk-player__time" aria-hidden="true">4:36</time>
|
||||
|
||||
<audio class="talk-player__audio" preload="none" src={src}></audio>
|
||||
</figure>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll<HTMLElement>(".talk-player").forEach((player) => {
|
||||
const audio = player.querySelector<HTMLAudioElement>(".talk-player__audio");
|
||||
const button = player.querySelector<HTMLButtonElement>(".talk-player__button");
|
||||
const play = player.querySelector<SVGElement>(".talk-player__icon--play");
|
||||
const pause = player.querySelector<SVGElement>(".talk-player__icon--pause");
|
||||
const progress = player.querySelector<HTMLElement>(".talk-player__progress");
|
||||
const time = player.querySelector<HTMLElement>(".talk-player__time");
|
||||
if (!audio || !button) return;
|
||||
|
||||
const clock = (seconds: number) => {
|
||||
const whole = Math.floor(seconds);
|
||||
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const showPlay = (visible: boolean) => {
|
||||
play?.toggleAttribute("hidden", !visible);
|
||||
pause?.toggleAttribute("hidden", visible);
|
||||
button.setAttribute("aria-label", visible ? "Play the talk" : "Pause the talk");
|
||||
};
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
// Pause any other player on the page before starting this one.
|
||||
document.querySelectorAll<HTMLAudioElement>(".talk-player__audio").forEach((other) => {
|
||||
if (other !== audio) other.pause();
|
||||
});
|
||||
audio.paused ? audio.play() : audio.pause();
|
||||
});
|
||||
|
||||
audio.addEventListener("play", () => showPlay(false));
|
||||
audio.addEventListener("pause", () => showPlay(true));
|
||||
audio.addEventListener("ended", () => {
|
||||
showPlay(true);
|
||||
if (progress) progress.style.width = "0%";
|
||||
});
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
if (progress && audio.duration) {
|
||||
progress.style.width = `${(audio.currentTime / audio.duration) * 100}%`;
|
||||
}
|
||||
if (time && audio.duration) {
|
||||
time.textContent = `${clock(audio.currentTime)} / ${clock(audio.duration)}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@ -37,6 +37,7 @@ hero:
|
||||
---
|
||||
|
||||
import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||
import TalkPlayer from '../../components/TalkPlayer.astro';
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Disposable sandboxes" icon="rocket">
|
||||
@ -59,6 +60,12 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
<TalkPlayer
|
||||
src="/audio/mcqemu-lightning-talk.mp3"
|
||||
title="Give an agent a hypervisor"
|
||||
subtitle="A short talk on building this, and the bug that taught me to check. Transcript on the talk page."
|
||||
/>
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
|
||||
144
docs-site/src/content/docs/talk.mdx
Normal file
144
docs-site/src/content/docs/talk.mdx
Normal file
@ -0,0 +1,144 @@
|
||||
---
|
||||
title: The talk
|
||||
description: A five-minute talk about building mcqemu, what it is like to let a model drive a virtual machine, and the bug that made me check things afterwards.
|
||||
sidebar:
|
||||
order: 2
|
||||
---
|
||||
|
||||
import TalkPlayer from '../../components/TalkPlayer.astro';
|
||||
|
||||
A short talk about building this: what it is like to hand a language model a
|
||||
hypervisor, and the one rule that turned out to matter in both the driving and
|
||||
the code.
|
||||
|
||||
<TalkPlayer
|
||||
src="/audio/mcqemu-lightning-talk.mp3"
|
||||
title="Give an agent a hypervisor"
|
||||
subtitle="Four and a half minutes. Synthesised narration, full transcript below."
|
||||
/>
|
||||
|
||||
The recording is a synthesised reading rather than a live one, so the transcript
|
||||
below is authoritative and every claim in it is something that actually
|
||||
happened during the build.
|
||||
|
||||
---
|
||||
|
||||
## Transcript
|
||||
|
||||
I want to start with a picture, because it is the whole talk in one frame.
|
||||
|
||||
That is a virtual machine. The operating system is KolibriOS, written almost
|
||||
entirely in assembly, and it boots to a full graphical desktop in about six
|
||||
seconds. The game of Tetris in the middle was opened, and played, by a language
|
||||
model. It scored eleven. Not good. Not zero.
|
||||
|
||||
There is no automation script behind that. The model called tools, looked at
|
||||
what came back, and decided what to do next.
|
||||
|
||||
### Why this is easier than it sounds
|
||||
|
||||
QEMU already speaks a machine protocol. QMP is JSON over a unix socket, and it
|
||||
has been sitting there for years waiting for something to talk to it. So the
|
||||
project is an MCP server that maps it onto tools an agent can call. Thirty
|
||||
three of them.
|
||||
|
||||
One detail matters more than the rest. Screenshots come from the framebuffer,
|
||||
and keys go in as scancodes, below the operating system. Nothing is installed
|
||||
in the guest. So this works at a BIOS menu, at a bootloader, and on an
|
||||
operating system from 1998 that has never heard of you.
|
||||
|
||||
### Look, act, look
|
||||
|
||||
Now the part I actually want to talk about.
|
||||
|
||||
My first attempt to drive that desktop was a tidy sequence of keystrokes. Open
|
||||
the menu, arrow up to Game Center, press Enter. I sent it blind, the way you
|
||||
would write a shell script.
|
||||
|
||||
The screenshot afterwards showed a dialog box. The highlight had not been where
|
||||
I assumed. The item I actually landed on was **Shutdown**, and the dialog was
|
||||
asking if I would like to reboot, with Enter as the default.
|
||||
|
||||
I was one keystroke from turning off the machine I was trying to drive.
|
||||
|
||||
What saved it was not cleverness. It was taking a screenshot before pressing
|
||||
the next key. Look, act, look. Every time I skipped the second look, I got
|
||||
surprised.
|
||||
|
||||
### Fifteen rounds, then two
|
||||
|
||||
It got worse before it got better. KolibriOS ignores the device that lets you
|
||||
click an exact pixel, so the cursor had to be driven like a real PS/2 mouse.
|
||||
Relative motion has no idea where it currently is, and the guest applies its
|
||||
own acceleration, so a hundred pixels is not a hundred pixels.
|
||||
|
||||
The technique that worked is the one you would use in the dark. Shove the
|
||||
cursor into a corner until it stops, because now you know where it is. Move in
|
||||
small steps. Screenshot and check before you click.
|
||||
|
||||
That took fifteen rounds of trial and error. So I put it in the tool. The
|
||||
rematch, cold boot to Tetris running, took two calls.
|
||||
|
||||
Do the fiddly thing once, by hand, badly. Then put the lesson in the tool so
|
||||
nobody does it by hand again.
|
||||
|
||||
### The worst thing the review found
|
||||
|
||||
Then I had the code reviewed properly, in the Margaret Hamilton style. Thirty
|
||||
two findings. Here is the worst one, because it is embarrassing and because I
|
||||
think it is common.
|
||||
|
||||
The tool that destroys a sandbox tells QEMU to quit, waits ten seconds for the
|
||||
process to die, then deletes the disk.
|
||||
|
||||
Read that again. It waits ten seconds, and then it deletes the disk. It never
|
||||
looks at whether the process died. If the VM was wedged, the delete happened
|
||||
anyway, out from under a machine that still had the file open.
|
||||
|
||||
And the delete itself was written like this:
|
||||
|
||||
```python
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
```
|
||||
|
||||
That flag turns "I could not delete this" into silence. The function then
|
||||
returned `destroyed: True`. Unconditionally. It reported success without ever
|
||||
asking.
|
||||
|
||||
Both problems are one `if` statement away from correct. That is why it is worth
|
||||
showing you. It is not exotic. It is the ordinary shape of a bug in code that
|
||||
waits for something.
|
||||
|
||||
### Two things the gates caught
|
||||
|
||||
Once I started checking properly, the checking kept paying.
|
||||
|
||||
An acceptance test against real QEMU found a bug in my own fix. A hundred and
|
||||
twenty five unit tests had missed it, because every one of them mocked the
|
||||
function involved.
|
||||
|
||||
And the audit before publishing found my source distribution had swallowed the
|
||||
entire documentation site, node modules and all. Seven thousand files, seventy
|
||||
one megabytes, for a package with thirty source files. Package versions are
|
||||
immutable once published, so that would have been permanent.
|
||||
|
||||
Neither was found by writing code. Both came from checking what I had actually
|
||||
produced, rather than what I meant to produce.
|
||||
|
||||
### The rule
|
||||
|
||||
So you can give a language model a hypervisor, and it will boot an operating
|
||||
system out of the Internet Archive and play Tetris in it. That part took an
|
||||
afternoon and it is genuinely fun.
|
||||
|
||||
The part worth keeping is smaller. Both halves of this ran on one rule. The
|
||||
model has to look at the screen before it presses the next key. The code has to
|
||||
check the process died before it deletes the disk.
|
||||
|
||||
Verify the thing you waited for. That is the talk.
|
||||
|
||||
---
|
||||
|
||||
The reliability thinking behind the second half is written up in more detail in
|
||||
[how mcqemu handles failure](/explanation/reliability/), and the driving
|
||||
technique in [seeing and driving a VM](/explanation/see-and-drive/).
|
||||
@ -139,3 +139,195 @@
|
||||
.hero > .stack {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
/* ---- Recorded talk player ------------------------------------------- */
|
||||
|
||||
.talk-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
.talk-player__button {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.9rem;
|
||||
height: 2.9rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--sl-color-accent);
|
||||
color: var(--sl-color-black);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.talk-player__button:hover {
|
||||
background: var(--sl-color-accent-high);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.talk-player__button:focus-visible {
|
||||
outline: 2px solid var(--sl-color-accent-high);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.talk-player__icon {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
}
|
||||
|
||||
.talk-player__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.talk-player__title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.talk-player__subtitle {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--sl-color-gray-3);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.talk-player__track {
|
||||
margin-top: 0.55rem;
|
||||
height: 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: var(--sl-color-gray-5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.talk-player__progress {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--sl-color-accent);
|
||||
transition: width 0.2s linear;
|
||||
}
|
||||
|
||||
.talk-player__time {
|
||||
flex: none;
|
||||
font-family: var(--sl-font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--sl-color-gray-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- "A Supported Systems Joint" badge -------------------------------- */
|
||||
|
||||
.joint {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 2.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.joint__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--sl-color-gray-6);
|
||||
color: var(--sl-color-gray-2);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.joint__badge:hover {
|
||||
border-color: var(--sl-color-accent);
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
.joint__badge img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.joint__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgb(0 0 0 / 0.6);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.joint__backdrop[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.joint__card {
|
||||
position: relative;
|
||||
max-width: 26rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.8rem;
|
||||
background: var(--sl-color-gray-6);
|
||||
text-align: center;
|
||||
box-shadow: 0 1.5rem 4rem -1rem rgb(0 0 0 / 0.6);
|
||||
}
|
||||
|
||||
.joint__close {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0.4rem;
|
||||
border: 0;
|
||||
border-radius: 0.35rem;
|
||||
background: transparent;
|
||||
color: var(--sl-color-gray-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.joint__close:hover {
|
||||
background: var(--sl-color-gray-5);
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
.joint__mark {
|
||||
display: block;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.joint__title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.joint__text {
|
||||
margin: 0 0 1.4rem;
|
||||
color: var(--sl-color-gray-3);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.joint__link {
|
||||
display: inline-block;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--sl-color-accent);
|
||||
color: var(--sl-color-black);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.joint__link:hover {
|
||||
background: var(--sl-color-accent-high);
|
||||
color: var(--sl-color-black);
|
||||
}
|
||||
|
||||
160
drafts/lightning-talk-2026-08-18.md
Normal file
160
drafts/lightning-talk-2026-08-18.md
Normal file
@ -0,0 +1,160 @@
|
||||
# Give an Agent a Hypervisor
|
||||
|
||||
**Lightning talk.** Five minutes as written without slide 6, just under six
|
||||
with it. Slide 6 is marked optional for exactly that reason: drop it if the
|
||||
timer is strict, keep it if you have the room. Timings below are cumulative and
|
||||
assume you skip it. Slide cues in brackets. Everything here happened; nothing
|
||||
is dramatized.
|
||||
|
||||
---
|
||||
|
||||
## [0:00] Slide 1: KolibriOS, with a game of Tetris running
|
||||
|
||||
I want to start with this picture, because it is the whole talk in one frame.
|
||||
|
||||
That is a virtual machine. The operating system is KolibriOS, written almost
|
||||
entirely in assembly, and it boots to a full graphical desktop in about six
|
||||
seconds. The game of Tetris in the middle was opened, and played, by a language
|
||||
model. It scored eleven. Not good. Not zero.
|
||||
|
||||
There is no automation script behind that. The model called tools, looked at
|
||||
what came back, and decided what to do next.
|
||||
|
||||
---
|
||||
|
||||
## [0:30] Slide 2: `QMP: JSON over a unix socket`
|
||||
|
||||
This is easier than it sounds, because QEMU already speaks a machine protocol.
|
||||
QMP is JSON over a unix socket, and it has been sitting there for years waiting
|
||||
for something to talk to it. So the project is an MCP server that maps it onto
|
||||
tools an agent can call. Thirty three of them.
|
||||
|
||||
One detail matters more than the rest. Screenshots come from the framebuffer,
|
||||
and keys go in as scancodes, below the operating system. Nothing is installed
|
||||
in the guest. So this works at a BIOS menu, at a bootloader, and on an OS from
|
||||
1998 that has never heard of you.
|
||||
|
||||
---
|
||||
|
||||
## [1:10] Slide 3: `look, act, look`
|
||||
|
||||
Now the part I actually want to talk about.
|
||||
|
||||
My first attempt to drive that desktop was a tidy sequence of keystrokes. Open
|
||||
the menu, arrow up to Game Center, press Enter. I sent it blind, the way you
|
||||
would write a shell script.
|
||||
|
||||
The screenshot afterwards showed a dialog box. The highlight had not been where
|
||||
I assumed. The item I actually landed on was **Shutdown**, and the dialog was
|
||||
asking if I would like to reboot, with Enter as the default.
|
||||
|
||||
I was one keystroke from turning off the machine I was trying to drive.
|
||||
|
||||
What saved it was not cleverness. It was taking a screenshot before pressing
|
||||
the next key. Look, act, look. Every time I skipped the second look, I got
|
||||
surprised.
|
||||
|
||||
---
|
||||
|
||||
## [1:55] Slide 4: fifteen rounds, then two
|
||||
|
||||
It got worse before it got better. KolibriOS ignores the device that lets you
|
||||
click an exact pixel, so the cursor had to be driven like a real PS/2 mouse.
|
||||
Relative motion has no idea where it currently is, and the guest applies its
|
||||
own acceleration, so a hundred pixels is not a hundred pixels.
|
||||
|
||||
The technique that worked is the one you would use in the dark. Shove the
|
||||
cursor into a corner until it stops, because now you know where it is. Move in
|
||||
small steps. Screenshot and check before you click.
|
||||
|
||||
That took fifteen rounds of trial and error. So I put it in the tool. The
|
||||
rematch, cold boot to Tetris running, took two calls.
|
||||
|
||||
Do the fiddly thing once, by hand, badly. Then put the lesson in the tool so
|
||||
nobody does it by hand again.
|
||||
|
||||
---
|
||||
|
||||
## [2:40] Slide 5: `ignore_errors=True`
|
||||
|
||||
Then I had the code reviewed properly, in the Margaret Hamilton style. Thirty
|
||||
two findings. Here is the worst one, because it is embarrassing and because I
|
||||
think it is common.
|
||||
|
||||
The tool that destroys a sandbox tells QEMU to quit, waits ten seconds for the
|
||||
process to die, then deletes the disk.
|
||||
|
||||
Read that again. It waits ten seconds, and then it deletes the disk. It never
|
||||
looks at whether the process died. If the VM was wedged, the delete happened
|
||||
anyway, out from under a machine that still had the file open.
|
||||
|
||||
And the delete itself was written like this.
|
||||
|
||||
```python
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
```
|
||||
|
||||
That flag turns "I could not delete this" into silence. The function then
|
||||
returned `destroyed: True`. Unconditionally. It reported success without ever
|
||||
asking.
|
||||
|
||||
Both problems are one `if` statement away from correct. That is why it is worth
|
||||
showing you. It is not exotic. It is the ordinary shape of a bug in code that
|
||||
waits for something.
|
||||
|
||||
---
|
||||
|
||||
## [optional, +45s] Slide 6: two things the gates caught
|
||||
|
||||
Once I started checking properly, the checking kept paying.
|
||||
|
||||
An acceptance test against real QEMU found a bug in my own fix. A hundred and
|
||||
twenty five unit tests had missed it, because every one of them mocked the
|
||||
function involved.
|
||||
|
||||
And the audit before publishing found my source distribution had swallowed the
|
||||
entire documentation site, node modules and all. Seven thousand files, seventy
|
||||
one megabytes, for a package with thirty source files. PyPI versions are
|
||||
immutable, so that would have been permanent.
|
||||
|
||||
Neither was found by writing code. Both came from checking what I had actually
|
||||
produced, rather than what I meant to produce.
|
||||
|
||||
---
|
||||
|
||||
## [4:00] Slide 7: the same picture again
|
||||
|
||||
So you can give a language model a hypervisor, and it will boot an operating
|
||||
system out of the Internet Archive and play Tetris in it. That part took an
|
||||
afternoon and it is genuinely fun.
|
||||
|
||||
The part worth keeping is smaller. Both halves of this ran on one rule. The
|
||||
model has to look at the screen before it presses the next key. The code has to
|
||||
check the process died before it deletes the disk.
|
||||
|
||||
Verify the thing you waited for. That is the talk.
|
||||
|
||||
It is on PyPI as mcqemu. Docs at mcqemu.warehack.ing.
|
||||
|
||||
---
|
||||
|
||||
## Notes for delivery
|
||||
|
||||
- The Shutdown dialog is the laugh. Do not rush it, and do not explain it
|
||||
afterwards.
|
||||
- Slide 5 is the load-bearing slide. Never cut it.
|
||||
- Slide 6 is the pressure valve. Drop it entirely for a hard five minutes, or
|
||||
keep only the PyPI story, which is the most concrete of the two.
|
||||
- Have the Tetris screenshot up during questions. People ask about it.
|
||||
- Spare thirty seconds if you need it: archive.org was serving 500s from a dead
|
||||
storage node that day, and the fix was asking its metadata API which mirror
|
||||
was healthy. Cut this first.
|
||||
|
||||
## Alternative angle, if the room is more ops than dev
|
||||
|
||||
Same demo, different spine: disposable VMs as agent sandboxes. A copy-on-write
|
||||
overlay means a fresh clone of a twenty gigabyte base image costs kilobytes and
|
||||
about a second. Outbound networking is off by default, because QEMU's user-mode
|
||||
networking maps the host loopback to 10.0.2.2, and "it is only on localhost" is
|
||||
exactly the assumption that breaks. Same closing rule, applied to the destroy
|
||||
path.
|
||||
53
drafts/lightning-talk-narration.txt
Normal file
53
drafts/lightning-talk-narration.txt
Normal file
@ -0,0 +1,53 @@
|
||||
Give an agent a hypervisor.
|
||||
|
||||
I want to start with a picture, because it is the whole talk in one frame.
|
||||
|
||||
That is a virtual machine. The operating system is KolibriOS, written almost entirely in assembly, and it boots to a full graphical desktop in about six seconds. The game of Tetris in the middle was opened, and played, by a language model. It scored eleven. Not good. Not zero.
|
||||
|
||||
There is no automation script behind that. The model called tools, looked at what came back, and decided what to do next.
|
||||
|
||||
This is easier than it sounds, because QEMU already speaks a machine protocol. Q M P is JSON over a unix socket, and it has been sitting there for years waiting for something to talk to it. So the project is an M C P server that maps it onto tools an agent can call. Thirty three of them.
|
||||
|
||||
One detail matters more than the rest. Screenshots come from the framebuffer, and keys go in as scancodes, below the operating system. Nothing is installed in the guest. So this works at a BIOS menu, at a bootloader, and on an operating system from 1998 that has never heard of you.
|
||||
|
||||
Now the part I actually want to talk about.
|
||||
|
||||
My first attempt to drive that desktop was a tidy sequence of keystrokes. Open the menu, arrow up to Game Center, press Enter. I sent it blind, the way you would write a shell script.
|
||||
|
||||
The screenshot afterwards showed a dialog box. The highlight had not been where I assumed. The item I actually landed on was Shutdown. And the dialog was asking if I would like to reboot, with Enter as the default.
|
||||
|
||||
I was one keystroke from turning off the machine I was trying to drive.
|
||||
|
||||
What saved it was not cleverness. It was taking a screenshot before pressing the next key. Look, act, look. Every time I skipped the second look, I got surprised.
|
||||
|
||||
It got worse before it got better. KolibriOS ignores the device that lets you click an exact pixel, so the cursor had to be driven like a real PS 2 mouse. Relative motion has no idea where it currently is, and the guest applies its own acceleration, so a hundred pixels is not a hundred pixels.
|
||||
|
||||
The technique that worked is the one you would use in the dark. Shove the cursor into a corner until it stops, because now you know where it is. Move in small steps. Screenshot and check before you click.
|
||||
|
||||
That took fifteen rounds of trial and error. So I put it in the tool. The rematch, cold boot to Tetris running, took two calls.
|
||||
|
||||
Do the fiddly thing once, by hand, badly. Then put the lesson in the tool so nobody does it by hand again.
|
||||
|
||||
Then I had the code reviewed properly, in the Margaret Hamilton style. Thirty two findings. Here is the worst one, because it is embarrassing, and because I think it is common.
|
||||
|
||||
The tool that destroys a sandbox tells QEMU to quit, waits ten seconds for the process to die, then deletes the disk.
|
||||
|
||||
Listen to that again. It waits ten seconds, and then it deletes the disk. It never looks at whether the process died. If the virtual machine was wedged, the delete happened anyway, out from under a machine that still had the file open.
|
||||
|
||||
And the delete itself called remove tree, with ignore errors set to true. That flag turns, I could not delete this, into silence. The function then returned destroyed, true. Unconditionally. It reported success without ever asking.
|
||||
|
||||
Both problems are one if statement away from correct. That is why it is worth showing you. It is not exotic. It is the ordinary shape of a bug in code that waits for something.
|
||||
|
||||
Once I started checking properly, the checking kept paying.
|
||||
|
||||
An acceptance test against real QEMU found a bug in my own fix. A hundred and twenty five unit tests had missed it, because every one of them mocked the function involved.
|
||||
|
||||
And the audit before publishing found my source distribution had swallowed the entire documentation site, node modules and all. Seven thousand files, seventy one megabytes, for a package with thirty source files. Package versions are immutable once published, so that would have been permanent.
|
||||
|
||||
Neither was found by writing code. Both came from checking what I had actually produced, rather than what I meant to produce.
|
||||
|
||||
So, you can give a language model a hypervisor, and it will boot an operating system out of the Internet Archive and play Tetris in it. That part took an afternoon, and it is genuinely fun.
|
||||
|
||||
The part worth keeping is smaller. Both halves of this ran on one rule. The model has to look at the screen before it presses the next key. The code has to check the process died before it deletes the disk.
|
||||
|
||||
Verify the thing you waited for. That is the talk.
|
||||
Loading…
x
Reference in New Issue
Block a user