persistent sessions for remote Jupyter  ·  CLI + VSCode extension

Your session outlives your connection.

Tithon keeps a Jupyter kernel — and every byte it ever printed — alive on the host, independent of any client. Close your laptop mid-run; reopen hours later over SSH or a VSCode tunnel. The output is still there, and still streaming.

$ pip install tithon
VSCode extension GitHub ↗

alpha — in daily use · rough edges · bug reports genuinely useful

18:21 training — client attached CONNECTED
train.py — notebook YOUR LAPTOP
# %% train for epoch in trange(48): loss = step(model)
0/48
tithon daemon GPU HOST
journal seq 0 sqlite · WAL kernel pid 71442 · setsid socket 0600
— nothing was lost. it never is. —
01 the problem

You SSH into a GPU box, start a long run in a notebook, and close your laptop. When you come back, the run is gone — or, if you were lucky, only everything it printed is.

WHERE YOUR SESSION LIVES TODAY, AND WHAT THAT COSTS YOU:

JupyterLab

Reconnects, but everything printed while you were away is gone. iopub output streams over the WebSocket and is never persisted server-side — there is nothing to replay.

✗ output lost on disconnect

VSCode Jupyter

Ties the kernel to the extension-host process. Close the window or drop the network and the kernel dies, taking the whole session — hours of state — with it.

✗ kernel dies with the client

tmux + jupyter console

Survives the disconnect, but you lose rich output — plots, HTML, widgets — and you can't open the same session from a second client.

✗ text only, single client

The root cause is the same in all three: the source of truth lives on the client. Tithon moves it to the host.

02 the machinery

A daemon owns the session.
The kernel answers to no one.

The kernel is plain ipykernel — Tithon replaces the session-management layer around it, not the execution engine. A long-lived daemon journals everything the kernel says and serves it back to any client, at any time, from any point in the stream. Full design in docs/SPEC.md.

setsid

Detached kernel

Kill −9 the daemon, upgrade it, restart it — the kernel keeps computing and re-attaches through a persisted connection file. The daemon is disposable; your state is not.

sqlite · WAL

Verbatim journal

Every iopub/shell message is persisted exactly as the kernel sent it, alongside a folded per-execution snapshot — so reconnects restore the current display without replaying history.

attach(last_seen_seq)

Snapshot + delta

Clients resume from the last sequence they saw: one snapshot, then an ordered, gapless delta stream. Reconnecting isn't a recovery procedure — it's just resuming.

.tithon/outputs/

Outputs as real files

Images are never base64-embedded. They're files referenced by hash and GC'd down to what the current display needs — a live plot converges to O(1) disk.

widget-state+json

Widgets come back live

ipywidgets traffic folds into a state mirror. A tqdm bar or a slider is restored at its real, current value — not a stale frame from when you left.

TITHON_SUB_QUEUE_MAX

Bounded backpressure

Per-subscriber buffers are capped; a client that falls too far behind is dropped and resyncs on reconnect. One slow reader can't grow daemon memory or block the rest.

03 in practice

Prove it in ninety seconds.

State lives under ~/.tithon — socket, log, journal, artifacts. Start the daemon, run something, then kill the daemon mid-flight. Watch nothing happen to your kernel.

Real VSCode, no cuts. A cell is mid-run when the daemon is killed — the connection drops, the daemon comes back, and the notebook re-attaches to the same kernel and resumes streaming into the same cell. No restart, no re-run, no lost kernel state.
kill the daemon — CLI
$ pip install tithon
$ tithon daemon &

$ tithon run -c 'x = 41'
$ tithon run -c 'x += 1; print(x)'
42

# now prove the point — kill the daemon.
$ pkill -9 -f 'tithon daemon'   # the daemon dies…
$ tithon daemon &                # …the kernel does not

$ tithon attach --since 0 --once # full snapshot: it's all back
$ tithon run -c 'print(x)'
42                              # state intact
1

Install the extension

code --install-extension rnoro.tithon — or search “tithon” in the Extensions view.

2

Open a .py as a notebook

A plain percent-format script (# %% cells) opens as a real notebook — same cells, same Run buttons, same rich output. The file stays pure source; outputs never touch it, so diffs stay clean.

3

Pick the Tithon kernel. That's all.

Selecting the kernel attaches the session. Reopen the notebook tomorrow — output, progress and widgets are restored and resume streaming, no command needed.

Over a Tunnel or Remote-SSH this is identical. The extension host runs on the remote, so it talks to the daemon's host-local socket directly — no port forwarding, nothing special for the remote case. The daemon and the extension must run on the same host: they share ~/.tithon.
Python 3.11+ Unix host — unix sockets + setsid Windows not supported VSCode for the notebook UI
04 clean source

Your notebook is a .py file.
It reads like one.

An .ipynb buries fifty lines of code in two hundred fifty lines of JSON, then base64-encodes the plots into it. Tithon keeps source as source: outputs live in the journal, images are real files — ones a coding agent can hand to a model as actual images it can see, not tokens it burns and still can't read.

train.ipynb ≈ 250 LINES OF JSON
{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {"collapsed": false},
   "outputs": [
    {
     "data": {
      "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAHg
       CAYAAAA10dzkAAAAOXRFWHRTb2Z0d2FyZQBNYXRw
       bG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8v
       …41 more lines of base64…",
      "text/plain": ["<Figure size 640x480>"]
     },
     "output_type": "display_data"
    }
   ],
   "source": ["for epoch in trange(48):\n", …
train.py ≈ 50 LINES OF PYTHON
# %% load
df = pd.read_parquet("runs/07-04.parquet")

# %% train
for epoch in trange(48):
    loss = step(model)

# %% plot
plt.plot(history["loss"])

# outputs → journal · images → .tithon/outputs/
# the .py stays pure source. diffs stay clean.

Don't feed your LLM idiot JSON.