Τιθωνός  ·  persistent sessions for remote Jupyter

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. —
α´ the curse

Ἠώς, goddess of the dawn, asked Zeus to make her lover immortal — and forgot to ask that he stay young. Tithonus lived forever, withering. A remote kernel bears the same curse: the process outlives your disconnect, but everything it says is lost the moment you leave.

TITHON LIFTS THE CURSE — IMMORTALITY, WITH THE ETERNAL YOUTH THIS TIME.
( AND IT RHYMES WITH PYTHON. )

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.

β´ 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.

γ´ 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.

the resurrection demo — 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
α´

Install the extension

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

β´

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.

γ´

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.
δ´ 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.