What is new here, in five lines
- The cost sits where it belongs. Someone who comes to read the README downloads not one byte of interpreter. The Python module lives in its own chunk and is only requested when someone types the command.
- The host program is written in Python, not in JavaScript templates. That keeps exception line numbers stable and makes it possible to trim the terminal’s own frames out of a traceback.
- It is a real REPL, on
codeop.CommandCompilerinsinglemode: that is why typing2 + 2prints4withoutprint(), and why an openforasks for more lines instead of blowing up. - The
.pyfiles travel inside the chunk, embedded at build time. Running a script fires no extra network request at all. - One instance, cached deliberately. Leaving the REPL and coming back re-attaches the same interpreter, with your variables where you left them.
What it is
The terminal drawer on this desktop is not a prop. Type python and it downloads a full CPython 3.14 compiled to WebAssembly — via Pyodide — that then runs inside your tab. From there the prompt becomes >>> and everything you type is executed by a real Python interpreter.
There is no API on the other side evaluating your code. Cut the network after it loads and it keeps working.
How it works
1. No interpreter until you ask for one
The module that knows about Python is imported dynamically, so Vite splits it into its own chunk. Nothing heavy is imported at the top of the file, on purpose:
const { load } = await import('./py/run');
The desktop’s main bundle never mentions the interpreter. The chunk weighs about 9 KB — scripts included — and the runtime’s ~6 MB comes from a CDN only when needed. The size warning shown beforehand is measured, not guessed.
2. The host program lives inside the interpreter
I could have composed each run by concatenating Python strings from JavaScript. Instead there is a small host program that runs once at startup and defines four functions. Running a script is then a call, not a template:
def __masami_trace(exc):
"""Print the traceback without this host's own frame."""
tb = exc.__traceback__
traceback.print_exception(type(exc), exc, tb.tb_next if tb else None)
That tb.tb_next is the detail that matters: someone who runs scoring.py and gets it wrong sees their error, not the terminal’s scaffolding stacked on top. And because the host is a real file rather than a string assembled on the fly, exception line numbers stay stable.
runpy.run_path runs the script with __name__ = "__main__", and SystemExit is caught separately: argparse exits that way both on --help and on a usage error, and a script finishing under its own steam is not an interpreter failure.
3. A REPL, not an eval()
The difference between a console and an evaluator is the compile mode:
code = __masami_compile(source, "<console>", "single")
single mode is what sends bare expressions through the displayhook so their repr lands on stdout — the echo you expect from >>>. And when CommandCompiler returns None it means “this is not a complete statement yet”: a for, a def, an unclosed bracket. The buffer is kept, the prompt switches to ..., and more lines are requested.
exit() is handled on the JavaScript side rather than in Python, for two reasons: in Pyodide that builtin comes from site, which is not always present, and what needs closing is the drawer, not the interpreter. It only counts outside an open block — inside a for, exit() is just another line of code and runs as one.
4. The scripts travel inside the chunk
const SOURCES = import.meta.glob('../../../scripts/*.py', {
query: '?raw', import: 'default', eager: true,
});
eager looks like it contradicts the lazy loading, but it does not: it resolves at build time and the result is embedded in a module that is itself only downloaded on demand. Serving them over HTTP would have meant an extra request per run to move three kilobytes.
At startup they are written into the interpreter’s virtual filesystem and the working directory is set to the home. That is why python scoring.py works with no path — and why ./scoring.py, ~/scoring.py and scripts/scoring.py all normalise to the same thing, because inside everything lives flat.
5. One instance, and failure is not cached
Pyodide exposes no way to destroy itself, so creating a second instance would leak the first with its interpreter inside. The promise is cached. But a network failure must not leave a rejected promise cached forever:
instance.catch(() => { instance = null; });
So the second attempt starts from scratch instead of replaying the same error forever.
6. Colour comes from the stream, not from guessing
setStdout and setStderr are registered once and point at a mutable sink. Whatever a script sends to stderr is painted red even if it does not look like an error, and whatever goes to stdout is green even if it says “ERROR”. The interpreter already knows which channel it is speaking on; there is no need to infer it from the text.
One small, annoying detail: batched hands over the buffer with its trailing newline included. Without trimming it, every print() would leave a blank line behind.
The two scripts
Neither is filler, and neither is a hello world:
scoring.pyscores a 30 m round — 36 arrows, 360 possible — and works out the gap to 300. What it actually measures is not the average but the variance: a 9 average with zero deviation is 324; a 9 average bouncing between 10 and 7 is a round that collapses on its own.escalate.pymodels when an agent should stop trying and call a human. The rule is not “low confidence”: it is(1 - confidence) × cost_of_error > cost_of_interruption. The interesting part is the indifference point — with an error costing 500 and an interruption costing 20, the threshold lands at 0.96, so an agent that is “fairly sure” at 90% should be asking. Almost none of them do.
Both connect to things sitting in other windows of this desktop, which was half the point.
The decisions that define it
- A portfolio you can execute. Saying you know WebAssembly is a sentence; letting a visitor type
pythonand check for themselves is something else. - Nobody pays for what they do not use. Most visits will never open the terminal, and those visits download nothing.
- Logic in the language it belongs to. REPL handling, tracebacks and
SystemExitare Python problems and get solved in Python. JavaScript only orchestrates.
Alternatives considered and rejected
| Idea | Why it was dropped |
|---|---|
| A full Alpine i386 VM on CheerpX | It dragged in xterm.js plus a system image, and what it gave was an isolated shell — not an interpreter that can talk to the page |
| Running the code on a backend | It stops being interesting and makes me the owner of a remote evaluator for other people’s code |
Serving the .py files over HTTP | One network request per run to move three kilobytes |
| Building each run by concatenating strings | Unstable line numbers in exceptions and tracebacks with the scaffolding inside |
| A hand-written Python simulator | It lies the moment someone tries something I did not anticipate; this cannot lie, because it is CPython |