#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The shell → browser control channel: `OSC 5379`.A **web** shell runs on a PTY whose bytes are a WebSocket away from the page thatframes it (:mod:`euler_webshell.ws`), so a command can speak to the browser chrome bywriting a control sequence into its own stdout: `ESC ] 5379 ; <payload> BEL`.The counterpart is `packages/site/euler_site/content/assets/terminal.js`'s`registerOscHandler(5379, …)`, which parses the payload and asks its parent pageto act — the shell drives the chrome, never the other way round.Every payload starts with this shell's **key**, then the action: `<key>;<action>;…`. Theper-user service draws the key when it forks the shell, hands it over in the environment(`EULER_OSC_KEY`, taken back out at import so nothing the shell runs inherits it) and givesit to the page on attach. The page ignores a sequence without it, so text that merely*contains* `ESC ] 5379` — a file being printed, `eval` output, a merged commit message —cannot drive the chrome.The payloads after the key, one per action — the first field is always the action, and everypayload carries a *token* (:func:`token`):- `open;<NNNN>;<token>` — show problem *NNNN* in the left pane.- `edit;<NNNN>;<token>;<relpath>` — open *relpath* in the pane's editor. The token sits *before* the path because a relpath may itself contain `;`: it is the one field that must stay last and be rejoined by the reader.- `nav;<token>;<path>` — swap the left pane to `/<path>`, any site route (`show <path>`). The path is relative (no leading `/`) and last, rejoined like `edit`'s.- `git;<token>` — this clone's git state changed; re-read the header's chip.- `msg;<token>` — this user's mailbox changed (they read, sent, replied or dismissed); re-read the header's message chip.- `account;<token>` — this user's account state changed (a new identity, a GitHub sign-in); re-read the account page — *if it is the visible pane*.- `progress;<token>` — `problems.json` changed (`summary`, `mark`); re-read the solutions grid — *if it is the visible pane*.- `logout;<token>;<seconds>` — the user ran `logout`: draw the notice, count *seconds* down and sign out (:mod:`euler_webshell.shell.logout`).- `busy;<token>` / `ready;<token>` — the shell has lent the terminal to an interactive child (`! sh`, `! py`, `claude`, …) / taken it back (:func:`handoff`). Unlike the rest these are a **state**, not an act: while busy the page obeys no other action and disables every control that types a command, since the keystrokes would land in the child, not the shell.- `running;<token>` / `idle;<token>` — the shell left its prompt to run a block / came back to it (:func:`working`). Also a **state**, but the page has no use for it: the per-user service reads it from the PTY stream (`ws/manager.py` § `TermModes`), which it sees with no tab open.The token is a server-side millisecond clock, strictly increasing per command. Onattach the service replays the scrollback, which redraws the sequences of commandsthat already ran; the token lets the browser tell a replay from a fresh act and obeyonly the latter (`terminal.js`, § the OSC handler). `busy` / `ready` are the exception:a reattach mid-subshell must come back disabled, so the page rebuilds that state fromthe replay too, the last of the pair winning.This module is the one definition of that wire, and it lives on the web side: the solvershell knows nothing of it. The *state-changed* payloads are sent by :func:`nudge`, the webshell's `notify` — `overlays.toml` names the commands after which each one fires(docs/package-guide.md § 3.2) — and the pane-swapping ones by the web shell's `show` / `edit`(:mod:`euler_webshell.shell.viewer`). The message commands, web-side themselves, emit directly;`busy` / `ready` are the web shell's `handoff` (:class:`~solver.shell.extension.Extension`)."""from__future__importannotations__all__=['EVENTS','OSC_CODE','OSC_KEY_ENV','account_changed','emit','git_changed','handoff','logout','messages_changed','nudge','progress_changed','token','working']importosimportsysimporttimefromtypingimportCallablefromeuler_webshell.ws.ptyimportOSC_KEY_ENVfromsolver.configimportconfig#: The OSC identifier shared with `terminal.js`'s `registerOscHandler`.OSC_CODE:int=5379#: This shell's key, popped from the environment on import so the commands the shell runs#: (`eval`, `!`) never inherit it. Empty off the web channel, where nothing is listening._KEY:str=os.environ.pop(OSC_KEY_ENV,'')deftoken()->int:"""A strictly increasing per-command stamp: the wall clock in milliseconds. Exposed because the token's *position* is part of each payload's shape (`edit` keeps its relpath last), so the caller places it — but no caller invents its own clock. """returntime.time_ns()//1_000_000defemit(action:str,*fields:str)->None:"""Write `ESC ] 5379 ; <action> ; <fields…> BEL` to stdout — web channel only. A no-op off the web channel: a terminal shell's stdout is a real terminal, and an escape sequence no one is listening for would just be noise in it. Callers therefore need no channel test for the *emission* — only for the user-facing difference (the viewer opens a named browser tab instead). """ifconfig.subject.channel!='web':returnsys.stdout.write(f'\x1b]{OSC_CODE};{";".join((_KEY,action,*fields))}\x07')sys.stdout.flush()defgit_changed()->None:"""Tell the page this clone's git state moved: the header re-reads its chip. Emitted after the commands that can change what the chip shows — a commit, a sync, a push, a wired filter, a regenerating verb's commit (`overlays.toml`). The header reads its state once per navigation and never polls, so without this the chip would be at its most wrong exactly after the user acted. It is only a *nudge*: the browser answers by re-reading `/git`, so a lost or replayed sequence costs at most a stale chip until the next navigation, never a wrong one. Because this rides the shell's own stdout, it fires for a hand-typed `git-sync` exactly as it does for the header menu's item — the menu types the same command, so there is one path, not two. """emit('git',str(token()))defmessages_changed()->None:"""Tell the page this user's mailbox moved: the header re-reads its message chip. Emitted by the `msg` command on every path that changes what the chip shows — reading a thread (the unread count drops), sending, replying, dismissing. This is the **shell's** half of a two-sided nudge, and the distinction matters. When a message *arrives*, the sender's spool pushes to the recipient's instance and the page hears it as a WebSocket text frame (web-server-guide § Messaging): the browser could not have known, because someone else acted. When the user *reads* one, the act happened in their own shell — no service saw it, and a round trip out to the spool and back to say so would be inventing a second mechanism for something stdout already carries. Both land on the same `euler:message` body event, so the chip has one listener and does not care which side moved. Without it the chip is at its most wrong exactly after the user acted: they read the one unread thread and the badge still says 1 until the next full page load. """emit('msg',str(token()))defaccount_changed()->None:"""Tell the page this user's account state moved: refresh the account panel. Emitted after the commands that change what the account page shows about *you* — a new key pair (`user`), a GitHub sign-in (`git-identity`). The nudge reaches the page the same way `git;` does; the difference is on the browser side, where the listening element lives **inside** the account fragment, so it re-reads only when the account page is the visible pane and is a no-op otherwise (web-server-guide § The site). The chip's own git state is a separate concern: `git-sync` / `git-filter` change decrypt access too, but they emit :func:`git_changed`, and the account panel listens for that event as well — so those need not emit both. """emit('account',str(token()))defprogress_changed()->None:"""Tell the page `problems.json` moved: the solutions grid re-reads itself. Emitted after `summary` and `mark`, which re-write the progress file. It matters most for the web upload, which stores the pasted page and hands the import to `summary` in this terminal — so the grid the upload left on screen is stale until the shell finishes, and this is how it learns that it has. Like `account;`, the listener lives inside the pane (`solutions.html`), so it is a no-op unless the grid is the visible page. """emit('progress',str(token()))#: The state-changed events an overlay may emit, each to the function that sends it.EVENTS:dict[str,Callable[[],None]]={'git':git_changed,'msg':messages_changed,'account':account_changed,'progress':progress_changed,}defnudge(event:str)->None:"""The web shell's `notify`: send the *event* named in an overlay (:data:`EVENTS`)."""EVENTS[event]()deflogout(seconds:int)->None:"""Ask the page to count *seconds* down and sign out: only the browser holds the cookies."""emit('logout',str(token()),str(seconds))defworking(running:bool)->None:"""The web shell's `working`: `running` as a block starts, `idle` back at the prompt. How the service tells work from an idle prompt without typing into the terminal: a probe written to the PTY would land in whatever holds it. Waiting counts as work, so an idle `claude` or an open dialogue reads as running, and a job left in the background does not. """emit('running'ifrunningelse'idle',str(token()))defhandoff(taken:bool)->None:"""The web shell's `handoff`: `busy` as an interactive child takes the terminal, `ready` after. While a subshell, Python or Claude Code owns the PTY, a header verb would type its command into *that* program — `git-sync` at a bash prompt, or into a Python REPL. So the page is told, and greys those verbs out until the shell has the terminal back. The child cannot fake the `ready`: the key left the environment at import, so nothing the shell runs knows it. """emit('busy'iftakenelse'ready',str(token()))