okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnkernel: a job_stop frame lets /jobs stop remote background jobs57a82be

main
55.8 KiB1494 linesraw
1"""Remote kernels over SSH: every harness tool, on another machine.
2
3connect() stages albedo's python bundle on the target, boots the same kernel
4over one long-lived ssh connection, and answers its host-route calls against
5this session's daemon, so session tools (work, skills, cells) keep running
6where the daemon runs while machine tools (run, files) run on the target.
7Both kernels load the same content-hashed bundle, so a tool's remote shape
8matches its local one and results cross as real objects.
9
10Every call is one reference: the reply inlines the value when it can cross and
11keeps the object live over there otherwise, so methods can be called on the
12remote object either way. If the kernel cannot boot, the connection degrades
13to command mode -- only rem.run() -- and says so at connect.
14"""
15
16from __future__ import annotations
17
18from albedo_output import JobOutput
19
20import asyncio
21import base64
22import contextlib
23import json
24import os
25import shlex
26import struct
27import time
28import uuid
29from pathlib import Path
30from typing import Any, TypedDict
31
32from albedo_protocol import RemoteCall, RemoteMessage, parse_remote
33
34from albedo_api import PythonApi, ReadyList, Text, check_timeout, excerpt
35import albedo_bundle
36from albedo_values import InvalidValue, decode
37import albedo_shell
38import albedo_ssh
39from albedo_plugins.run import notice
40
41CONNECT_TIMEOUT = 60  # seconds before an unreachable target gives up
42HANDSHAKE_TIMEOUT = 60  # seconds for the remote kernel to say ready
43STAGE_TIMEOUT = 120  # seconds to move the bundle over one ssh stream
44INVOKE_TIMEOUT = 3600  # anti-wedge bound; the cell deadline is the real limit
45COMMAND_TIMEOUT = 120  # degraded-mode command default
46MAX_FRAME = 8 * 1024 * 1024  # control-frame ceiling, as in the kernel
47STDERR_TAIL = 65536  # bytes of ssh diagnostics kept for failures
48IMAGE_READ = 5 * 1024 * 1024 + 1  # one past a cell's image bytes, as local reads
49
50loop: asyncio.AbstractEventLoop
51host_call: Any
52HostErrorType: type[Exception]
53capture_factory: Any
54send_frame: Any
55attach_image: Any
56session_modules: list[str] = []
57
58
59class LiveJob(TypedDict):
60    """A job still running over ssh, as the supervisor lists it."""
61
62    id: str
63    command: str
64    service: bool  # nothing waits on its end
65    started: int  # wall-clock milliseconds
66
67
68MAX_LISTED_JOBS = 100  # as many rows as the supervisor lists for local jobs
69COMMAND_SCALARS = 4096  # a command's preview, as local jobs report it
70
71_fallback_live: dict[str, LiveJob] = {}  # degraded-mode jobs still running over ssh
72_fallback_jobs: dict[str, FallbackJob] = {}
73
74
75def _live_job(id: str, command: str, service: bool) -> LiveJob:
76    return {
77        "id": id,
78        "command": command[:COMMAND_SCALARS],
79        "service": service,
80        "started": time.time_ns() // 1_000_000,
81    }
82
83
84def _report_live() -> None:
85    """Tell the local supervisor which remote jobs are still live, so it lists
86    them beside its own and knows which will wake the session: every one not
87    started as a service.
88
89    Releasing the kernel would kill every ssh client and with them the remote
90    kernels, so the idle sweep keeps a kernel alive while remote work runs,
91    and the cache warmer keeps an idle session warm while one it awaits does.
92    """
93    try:
94        live = [
95            *_fallback_live.values(),
96            *(job for c in connections for job in c._live.values()),
97        ]
98        send_frame(
99            {
100                "type": "jobs",
101                "live": len(live),
102                "awaited": sum(not job["service"] for job in live),
103                "items": live[:MAX_LISTED_JOBS],
104            }
105        )
106    except Exception:
107        pass  # no channel or shutdown; the supervisor keeps the last count
108
109
110configured: dict[str, str | None] = {}
111connections: list["RemoteConnection"] = []
112
113_UNSET = object()  # a value result that has not settled yet
114
115
116class RemoteError(RuntimeError):
117    """A remote operation failed; the message names what happened."""
118
119
120class RemoteBootError(RemoteError):
121    """The remote kernel could not start; the connection degrades to commands."""
122
123
124class RemoteLost(RemoteError):
125    """The ssh channel is gone; every reference it held is invalid."""
126
127
128class RemoteTimeout(RemoteError):
129    """A remote call did not answer; an interrupt was sent."""
130
131
132class RemoteCancelled(RemoteError):
133    """The wait was cancelled; the remote effect may continue."""
134
135
136class RemoteExecutionError(RemoteError):
137    """A remote tool call raised; its traceback crossed the connection."""
138
139    def __init__(self, ename: str, evalue: str, traceback: list[str]):
140        lines = "\n".join(traceback[-4:])
141        super().__init__(f"{ename}: {evalue}" + (f"\n{lines}" if lines else ""))
142        self.ename = ename
143        self.evalue = evalue
144        self.traceback = tuple(traceback)
145
146
147def parse_target(arg: str) -> dict[str, str | None]:
148    """Split `user@host[:/path]` or `ssh://user@host[:port][/path]`. ssh takes
149    the URI as its target, path left out; in the scp form a colon not followed
150    by a path stays in the host."""
151    if arg.startswith("ssh://"):
152        authority, slash, path = arg.removeprefix("ssh://").partition("/")
153        return {
154            "host": f"ssh://{authority}",
155            "remote_cwd": f"/{path}" if slash else None,
156        }
157    head, separator, tail = arg.partition(":")
158    remote_cwd = tail if separator and tail.startswith("/") else None
159    return {"host": arg if remote_cwd is None else head, "remote_cwd": remote_cwd}
160
161
162def _settings() -> dict[str, str]:
163    """The `remote` section of extensions.json, falling back to `ssh`; errors raise."""
164    home = os.environ.get("ALBEDO_HOME") or os.path.expanduser("~/.albedo")
165    try:
166        with open(os.path.join(home, "extensions.json"), "rb") as handle:
167            sections = json.load(handle)
168    except FileNotFoundError:
169        return {}
170    if not isinstance(sections, dict):
171        raise RemoteError("extensions.json must hold an object")
172    section = sections.get("remote", sections.get("ssh"))
173    if section is None:
174        return {}
175    if not isinstance(section, dict):
176        raise RemoteError("extensions.json remote section must be an object")
177    keys = ("host", "remoteCwd", "python")
178    for key in keys:
179        if key in section and not isinstance(section[key], str):
180            raise RemoteError(f"extensions.json remote.{key} must be a string")
181    return {key: section[key] for key in keys if key in section}
182
183
184def resolve(
185    host: str | None = None, remote_cwd: str | None = None, python: str | None = None
186) -> dict[str, str | None]:
187    """The target a connection runs against, or a misuse error naming every source."""
188    if host is None and configured:
189        target = dict(configured)
190    else:
191        if host is None:
192            host = os.environ.get("ALBEDO_SSH") or _settings().get("host")
193        if host is None:
194            raise RemoteError(
195                "no remote target: pass host=, call remote.configure(), set $ALBEDO_SSH, "
196                'or add a "remote" section to extensions.json'
197            )
198        target = parse_target(host)
199        if target["remote_cwd"] is None:
200            target["remote_cwd"] = _settings().get("remoteCwd")
201    if remote_cwd is not None:
202        target["remote_cwd"] = remote_cwd
203    if python is None:
204        python = _settings().get("python")
205    target["python"] = python
206    return target
207
208
209def refusal(host: str, stderr: str) -> str:
210    """Why ssh could not reach `host`; a sign-in only a person can give names
211    the command that opens the master this connection would ride."""
212    if albedo_ssh.failure(stderr)[0] != "needs_auth":
213        return f"ssh target {host!r} is unreachable: {stderr.strip()[:300]}"
214    return (
215        f"ssh target {host!r} needs a person to sign in: {stderr.strip()[:300]}\n"
216        f"ask the user to run `{shlex.join(albedo_ssh.sign_in(host))}` in a terminal,"
217        " then connect again; the master it opens carries this connection"
218    )
219
220
221ssh_env = albedo_ssh.env
222in_login_shell = albedo_ssh.in_login_shell
223ssh_base = albedo_ssh.base
224
225
226async def ssh_run(
227    target: str, script: str, *, timeout: float, stdin: bytes | None = None
228) -> tuple[int | None, bytes, str]:
229    """One command over the control connection; its exit code is data."""
230    process = await asyncio.create_subprocess_exec(
231        *ssh_base(target),
232        target,
233        script,
234        stdin=asyncio.subprocess.PIPE
235        if stdin is not None
236        else asyncio.subprocess.DEVNULL,
237        stdout=asyncio.subprocess.PIPE,
238        stderr=asyncio.subprocess.PIPE,
239        env=ssh_env(target),
240    )
241    try:
242        stdout, stderr = await asyncio.wait_for(process.communicate(stdin), timeout)
243    except asyncio.TimeoutError:
244        process.kill()
245        raise RemoteTimeout(
246            f"ssh command did not finish in {timeout:g}s: {script}"
247        ) from None
248    return process.returncode, stdout, stderr.decode(errors="replace")
249
250
251_BUNDLE: dict[str, str] = {}
252
253
254def bundle() -> dict[str, str]:
255    """Content hash of the packaged python tree, so both sides run the same code."""
256    if not _BUNDLE:
257        root = albedo_bundle.ROOT
258        digest = albedo_bundle.digest(root)
259        _BUNDLE["id"] = digest
260        _BUNDLE["root"] = str(root)
261        _BUNDLE["remote"] = f"$HOME/{albedo_ssh.staged_name(digest)}"
262    return _BUNDLE
263
264
265async def stage(target: str) -> None:
266    """Move the bundle over one ssh stream, as the daemon's host probe does."""
267    script = albedo_ssh.stage_script(bundle()["remote"])
268    code, _, stderr = await ssh_run(
269        target,
270        in_login_shell(script),
271        timeout=STAGE_TIMEOUT,
272        stdin=albedo_ssh.archive(),
273    )
274    if code != 0:
275        raise RemoteBootError(f"staging failed ({code}): {stderr.strip()[:500]}")
276
277
278def _revive(value: Any) -> Any:
279    """Pickle hook target: a reference with a crossed value saves as its value."""
280    return value
281
282
283class RemoteRef:
284    """One remote reference: pending, live, or settled into its value.
285
286    A call returns this immediately -- the invoke is already in flight, no
287    round trip blocks the call -- and the reference settles on first await.
288    Methods called before the reply lands target the pending call, so
289    `rem.run(program, ...)` behaves like local run: the handle exists now, `await`
290    waits for the object, and mirrored job state answers tail() and poll()
291    locally, with no round trip. A result whose value crossed reads and
292    iterates like that value; its local methods run locally, and attributes
293    that only exist remotely are remote calls.
294    """
295
296    def __init__(
297        self,
298        connection: "RemoteConnection",
299        handle: str | None = None,
300        value: Any = None,
301        task: "asyncio.Task[Any] | None" = None,
302        call_id: str | None = None,
303        label: str = "",
304    ) -> None:
305        self._connection = connection
306        self._handle = handle
307        self._value = value
308        self._task = task
309        self._call_id = call_id
310        self._label = label
311        self._settled: Any = _UNSET
312        self._withdrawn: bool = False
313        self._awaited = False  # an await here returned, so its result reached this cell
314        self._waiters = 0  # awaits here still waiting on it
315
316    # --- mirrored job state: sync, like the local handle ---
317
318    def tail(self, n: int = 4000, *, lines: int | None = None) -> Text:
319        """The recent output of the remote job this reference holds: the last
320        n characters, or its last `lines` lines."""
321        state = self._mirror(read=False)
322        text = str(state.get("tail", "")) if state else ""
323        size = state.get("tail_bytes", len(text.encode())) if state else 0
324        result = excerpt(
325            text,
326            n,
327            lines,
328            end=True,
329            clipped=bool(state and state.get("seen", 0) > size),
330        )
331        self._mirror()
332        return result
333
334    def poll(self) -> int | None:
335        """The remote job's exit status so far, from the mirrored stream."""
336        state = self._mirror()
337        return state.get("exit_code") if state else None
338
339    @property
340    def exit_code(self) -> int | None:
341        """The remote job's exit status; the mirror keeps it current."""
342        return self.poll()
343
344    @property
345    def returncode(self) -> int | None:
346        """`exit_code` under subprocess's name; both spellings answer."""
347        return self.poll()
348
349    @property
350    def timed_out(self) -> bool:
351        """Whether the remote job lost its deadline."""
352        state = self._mirror()
353        return bool(state.get("timed_out")) if state else False
354
355    @property
356    def duration(self) -> float | None:
357        """The seconds the remote job ran, known once it ended."""
358        state = self._mirror()
359        return state.get("duration") if state else None
360
361    @property
362    def id(self) -> str | None:
363        """The remote job's id, for output.read on the remote side."""
364        state = self._mirror()
365        return state.get("job") if state else None
366
367    def _mirror(self, *, read: bool = True) -> dict[str, Any] | None:
368        if (failure := self._failure()) is not None:
369            raise failure
370        if self._handle is None:
371            return None
372        state = self._connection._mirrors.get(self._handle)
373        # A finished job read through its mirror is consumed on the remote side
374        # too, or its kernel would keep retrying a wake this read satisfies.
375        if (
376            read
377            and state is not None
378            and state.get("exit_code") is not None
379            and not self._withdrawn
380        ):
381            self._withdrawn = True
382            try:
383                _ = loop.create_task(self._connection.mark_read(self._handle))
384            except RuntimeError:
385                pass  # the loop is closing; the notice dies with the kernel
386        return state
387
388    def _failure(self) -> BaseException | None:
389        """The error of a call that failed before anything awaited it. A ref
390        nobody awaits (a long build left to wake the session) has no other way
391        to say it never started."""
392        task = self._task
393        if task is None or not task.done() or task.cancelled():
394            return None
395        return task.exception()
396
397    @property
398    def _claimed(self) -> bool:
399        """Its result reached this side, or an await here is about to take it,
400        so the job's wake is not owed."""
401        return self._awaited or self._waiters > 0 or self._withdrawn
402
403    # --- the value surface: identical output to the local tool ---
404
405    def __repr__(self) -> str:
406        if (failure := self._failure()) is not None:
407            return f"<failed remote {self._label or 'call'}: {failure}>"
408        if self._value is not None:
409            return repr(self._value)
410        if self._handle is not None:
411            return f"<remote reference {self._handle[:8]} on {self._connection.host}>"
412        return f"<pending remote {self._label or 'call'}; await it>"
413
414    __str__ = __repr__
415
416    def _wanted(self) -> Any:
417        if self._value is None and self._handle is None:
418            raise TypeError(
419                f"the remote {self._label or 'result'} is still pending; await it first"
420            )
421        if self._value is None:
422            raise TypeError(
423                "a live remote reference is not a value; await it or call its methods"
424            )
425        return self._value
426
427    def __iter__(self):
428        return iter(self._wanted())
429
430    def __len__(self) -> int:
431        return len(self._wanted())
432
433    def __getitem__(self, key: Any) -> Any:
434        return self._wanted()[key]
435
436    def __contains__(self, key: Any) -> bool:
437        return key in self._wanted()
438
439    def __eq__(self, other: object) -> bool:
440        return self._value == other if self._value is not None else self is other
441
442    def __dir__(self) -> list[str]:
443        return sorted(
444            set(dir(type(self)))
445            | (set(dir(self._value)) if self._value is not None else set())
446        )
447
448    def __reduce__(self):
449        if self._value is None:
450            raise TypeError(
451                "a live remote reference cannot be saved; await or release it first"
452            )
453        return _revive, (self._value,)
454
455    # --- the remote surface ---
456
457    def __getattr__(self, name: str) -> Any:
458        if name.startswith("_"):
459            raise AttributeError(name)
460        if self._value is not None and hasattr(self._value, name):
461            return getattr(self._value, name)
462        if self._connection.closed:
463            raise RemoteLost(
464                f"the connection for reference {self._label or '?'} is closed"
465            )
466        if self._handle is not None:
467            return _RemoteCall(self._connection, self._handle, name)
468        return _RemoteCall(
469            self._connection,
470            None,
471            name,
472            pending=self._call_id,
473            label=f"{self._label}.{name}" if self._label else name,
474        )
475
476    def __await__(self):
477        return self._wait().__await__()
478
479    async def _wait(self) -> Any:
480        # Counted now, like a local job: the await crosses the connection only
481        # after the reply to the call, and a quick job is done before that. An
482        # await cut short interrupts the remote one too, so the remote job owes
483        # its wake again and this reference no longer claims it.
484        self._waiters += 1
485        try:
486            result = await self._settle()
487            self._awaited = True
488        finally:
489            self._waiters -= 1
490        return result
491
492    async def _settle(self) -> Any:
493        """Resolve the in-flight call; awaiting a live object awaits the object."""
494        if self._task is not None:
495            task, self._task = self._task, None
496            result = await task
497            if result is not self:
498                self._settled = result
499                return result
500        if self._settled is not _UNSET:
501            return self._settled
502        if self._handle is not None and self._value is None:
503            await self._connection.invoke(handle=self._handle, wait=True)
504        return self
505
506    async def release(self) -> None:
507        """Drop the live reference; the object itself is the remote plugin's to keep."""
508        if self._task is not None:
509            self._task.cancel()
510            self._task = None
511            return
512        if self._handle is not None:
513            self._connection._refs.pop(self._handle, None)
514            await self._connection.invoke_release(self._handle)
515        self._value = None
516
517
518class _RemoteCall:
519    """A method of the remote namespace, of one live reference, or of a pending call."""
520
521    def __init__(
522        self,
523        connection: "RemoteConnection",
524        handle: str | None,
525        name: str,
526        pending: str | None = None,
527        label: str | None = None,
528    ) -> None:
529        self._connection = connection
530        self._handle = handle
531        self._name = name
532        self._pending = pending
533        self._label = label or name
534
535    def __call__(self, *args: Any, **kwargs: Any) -> RemoteRef:
536        """Start the call now; the reference it produces settles on first await."""
537        return self._connection.start(self, args, kwargs)
538
539    def __await__(self):
540        """Awaiting an uncalled method runs it with no arguments, like a property."""
541        return self().__await__()
542
543    def __getattr__(self, name: str) -> Any:
544        if name.startswith("_"):
545            raise AttributeError(name)
546        return _RemoteCall(
547            self._connection,
548            self._handle,
549            f"{self._name}.{name}",
550            pending=self._pending,
551        )
552
553    def __repr__(self) -> str:
554        where = (
555            self._handle[:8]
556            if self._handle
557            else (self._pending[:8] if self._pending else "namespace")
558        )
559        return f"<remote call {self._name} on {where}>"
560
561
562SHELL_ONLY_DEGRADED = "rem.shell is only available in degraded SSH command mode; use rem.run(...).pipe(...) instead"
563
564
565class RemoteConnection:
566    """One ssh channel owning one remote kernel, or degraded to plain commands."""
567
568    def __init__(self, target: dict[str, str | None]) -> None:
569        self.target = target
570        self._process: asyncio.subprocess.Process | None = None
571        self._stdin = None
572        self._stdout = None
573        self._write_lock = asyncio.Lock()
574        self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
575        self._refs: dict[str, RemoteRef] = {}
576        self._mirrors: dict[str, dict[str, Any]] = {}
577        self._handshake: asyncio.Future[dict[str, Any]] | None = None
578        self._live: dict[str, LiveJob] = {}
579        self._stderr_tail = bytearray()
580        self._reader: asyncio.Task[None] | None = None
581        self._drain: asyncio.Task[None] | None = None
582        self.closed = False
583        self.degraded: RemoteBootError | None = None
584
585    # --- boot ---
586
587    async def boot(
588        self, modules: list[str] | None = None, timeout: float = HANDSHAKE_TIMEOUT
589    ) -> None:
590        """Stage, spawn, and wait for ready; a failure raises RemoteBootError."""
591        remote_root = bundle()["remote"]
592        script_path = f"{remote_root}/albedo_kernel.py"
593        code, _, _ = await ssh_run(
594            self.host,
595            in_login_shell(f'test -f "{script_path}"'),
596            timeout=CONNECT_TIMEOUT,
597        )
598        if code != 0:
599            await stage(self.host)
600        python = self.target.get("python") or "python3"
601        # The stamp reaches the remote kernel's plugins, so a finished remote
602        # job's wake notice names the machine it ran on.
603        inner = (
604            f"ALBEDO_REMOTE_TARGET={shlex.quote(self.host)} exec "
605            + f'{shlex.quote(python)} -u "{script_path}" '
606            + shlex.quote(
607                json.dumps(modules if modules is not None else default_modules())
608            )
609        )
610        remote_cwd = self.target.get("remote_cwd")
611        if remote_cwd:
612            inner = f"cd {shlex.quote(remote_cwd)} && {inner}"
613        script = in_login_shell(inner)
614        self._handshake = loop.create_future()
615        self._process = await asyncio.create_subprocess_exec(
616            *ssh_base(self.host),
617            self.host,
618            script,
619            stdin=asyncio.subprocess.PIPE,
620            stdout=asyncio.subprocess.PIPE,
621            stderr=asyncio.subprocess.PIPE,
622            env=ssh_env(self.host),
623        )
624        self._stdin, self._stdout = self._process.stdin, self._process.stdout
625        self._drain = loop.create_task(self._drain_stderr())
626        self._reader = loop.create_task(self._read_frames())
627        try:
628            frame = await asyncio.wait_for(asyncio.shield(self._handshake), timeout)
629        except asyncio.TimeoutError:
630            raise RemoteBootError(f"kernel did not say ready in {timeout:g}s") from None
631        if frame.get("type") != "ready":
632            raise RemoteBootError(str(frame.get("message", "invalid kernel handshake")))
633        self._handshake = None
634
635    async def _drain_stderr(self) -> None:
636        """Keep a bounded tail of ssh diagnostics; a banner must not wedge the pipe."""
637        assert self._process is not None and self._process.stderr is not None
638        while True:
639            chunk = await self._process.stderr.read(4096)
640            if not chunk:
641                return
642            self._stderr_tail.extend(chunk)
643            del self._stderr_tail[:-STDERR_TAIL]
644
645    # --- frames ---
646
647    async def _read_frames(self) -> None:
648        try:
649            while True:
650                assert self._stdout is not None
651                header = await self._stdout.readexactly(4)
652                size = struct.unpack(">I", header)[0]
653                if size > MAX_FRAME:
654                    raise RemoteLost("remote control frame exceeds the ceiling")
655                frame = parse_remote(json.loads(await self._stdout.readexactly(size)))
656                self._dispatch(frame)
657        except asyncio.CancelledError:
658            raise
659        except Exception:
660            pass  # Every reader failure invalidates its outstanding work below.
661        finally:
662            self._lost()
663
664    def _dispatch(self, frame: RemoteMessage) -> None:
665        if (
666            frame["type"] == "invoked"
667            or frame["type"] == "introspected"
668            or frame["type"] == "job_stop"
669        ):
670            future = self._pending.get(frame["id"])
671            if future is not None and not future.done():
672                future.set_result(dict(frame))
673        elif frame["type"] in ("ready", "startup_error"):
674            if self._handshake is not None and not self._handshake.done():
675                self._handshake.set_result(dict(frame))
676        elif frame["type"] == "call":
677            _ = loop.create_task(self._relay(frame))
678        elif frame["type"] == "mirror":
679            self._mirrors[frame["handle"]] = dict(frame)
680        elif frame["type"] == "job_start":
681            self._live[frame["id"]] = _live_job(
682                frame["id"],
683                str(frame.get("command", "")),
684                frame.get("service") is True,
685            )
686            _report_live()
687        elif frame["type"] == "job":
688            self._live.pop(frame["id"], None)
689            _report_live()
690        # trace, done, cleanup describe remote cells; those are addressed
691        # through references, not events on this side.
692
693    async def _relay(self, frame: RemoteCall) -> None:
694        """Answer one remote host-route call against this session's daemon."""
695        try:
696            if frame["method"] == "jobs.completed":
697                value = await self._wake(frame["args"])
698            else:
699                value = await host_call(frame["method"], frame["args"])
700            reply: dict[str, Any] = {"ok": True, "value": value}
701        except HostErrorType as error:
702            reply = {
703                "ok": False,
704                "code": getattr(error, "code", "host"),
705                "message": str(getattr(error, "message", error)),
706            }
707        except Exception as error:  # the daemon's answer must always be a reply
708            reply = {
709                "ok": False,
710                "code": "host",
711                "message": f"{type(error).__name__}: {error}",
712            }
713        await self._send({"type": "reply", "id": frame["id"], "value": reply})
714
715    async def _wake(self, args: Any) -> Any:
716        """Pass on a remote job's wake unless a reference here already awaited
717        or read the job, which the remote kernel may not have heard yet."""
718        facts = [
719            fact
720            for fact in args.get("jobs", [])
721            if not self._claimed_here(fact.get("id"))
722        ]
723        if not facts:
724            return None
725        return await host_call("jobs.completed", notice(facts))
726
727    def _claimed_here(self, job: object) -> bool:
728        return any(
729            reference._claimed
730            for handle, reference in self._refs.items()
731            if self._mirrors.get(handle, {}).get("job") == job
732        )
733
734    async def _send(self, frame: dict[str, Any]) -> None:
735        if self.closed or self._stdin is None:
736            raise RemoteLost(f"the connection to {self.host} is closed")
737        data = json.dumps(frame).encode()
738        if len(data) > MAX_FRAME:
739            raise RemoteError(
740                f"remote call payload is {len(data)} bytes, over the {MAX_FRAME}-byte ceiling"
741            )
742        async with self._write_lock:
743            self._stdin.write(struct.pack(">I", len(data)) + data)
744            await self._stdin.drain()
745
746    def _lost(self) -> None:
747        """The channel died: every pending call and reference learns it once."""
748        if self._handshake is not None and not self._handshake.done():
749            self._handshake.set_exception(
750                RemoteBootError(
751                    "the ssh channel closed at startup: "
752                    + bytes(self._stderr_tail).decode(errors="replace").strip()[:500]
753                )
754            )
755        for future in self._pending.values():
756            if not future.done():
757                future.set_exception(
758                    RemoteLost(f"the connection to {self.host} was lost")
759                )
760        self.closed = True
761
762    # --- calls ---
763
764    def start(
765        self, call: "_RemoteCall", args: tuple[Any, ...], kwargs: dict[str, Any]
766    ) -> RemoteRef:
767        """Fire one call and return its reference immediately; it settles on await."""
768        call_id = uuid.uuid4().hex
769        reference = RemoteRef(self, call_id=call_id, label=call._label)
770        reference._task = loop.create_task(
771            self.invoke(
772                name=call._name,
773                handle=call._handle,
774                pending=call._pending,
775                args=args,
776                kwargs=kwargs,
777                call_id=call_id,
778                placeholder=reference,
779            )
780        )
781        return reference
782
783    async def invoke(
784        self,
785        name: str = "",
786        *,
787        handle: str | None = None,
788        pending: str | None = None,
789        args: tuple[Any, ...] = (),
790        kwargs: dict[str, Any] | None = None,
791        wait: bool = False,
792        timeout: float = INVOKE_TIMEOUT,
793        call_id: str | None = None,
794        placeholder: RemoteRef | None = None,
795    ) -> Any:
796        """One remote call; the answer is a value, or a reference to the live object.
797
798        A `pending` target names an earlier call whose result this one uses, so
799        method calls raced ahead of their own reply still resolve in order. The
800        `placeholder` is the reference the caller already holds; a handle reply
801        adopts into it rather than minting a second object.
802        """
803        if self.degraded is not None:
804            raise RemoteError(
805                f"degraded to ssh command mode ({self.degraded}); only rem.run() is available"
806            )
807        call_id = call_id or uuid.uuid4().hex
808        frame: dict[str, Any] = {
809            "type": "invoke",
810            "id": call_id,
811            "args": [_encode_arg(item) for item in args],
812            "kwargs": {key: _encode_arg(item) for key, item in (kwargs or {}).items()},
813        }
814        if name:
815            frame["name"] = name
816        if handle is not None:
817            frame["target"] = {"handle": handle}
818        elif pending is not None:
819            frame["target"] = {"pending": pending}
820        if wait:
821            frame["await"] = True
822        future: asyncio.Future[dict[str, Any]] = loop.create_future()
823        self._pending[call_id] = future
824        await self._send(frame)
825        try:
826            reply = await asyncio.wait_for(future, timeout)
827        except asyncio.TimeoutError:
828            await self._interrupt(call_id)
829            raise RemoteTimeout(
830                f"remote call {name or ('await ' + str(handle))!r} did not answer "
831                f"in {timeout:g}s; an interrupt was sent"
832            ) from None
833        except asyncio.CancelledError:
834            await self._interrupt(call_id)
835            raise
836        finally:
837            self._pending.pop(call_id, None)
838        return self._result(reply, placeholder)
839
840    def _result(
841        self, reply: dict[str, Any], placeholder: RemoteRef | None = None
842    ) -> Any:
843        """One reply into a value or a reference, or the error it names."""
844        if reply.get("ok") is not True:
845            error = reply.get("error") or {}
846            if reply.get("cancelled"):
847                raise RemoteCancelled(str(error.get("evalue", "cancelled remotely")))
848            raise RemoteExecutionError(
849                str(error.get("ename", "RemoteError")),
850                str(error.get("evalue", "")),
851                [str(line) for line in error.get("traceback", [])],
852            )
853        try:
854            value = decode(reply.get("value"))
855        except InvalidValue as error:
856            raise RemoteError(str(error)) from error
857        if "handle" in reply:
858            handle = str(reply["handle"])
859            state = reply.get("state")
860            if state is not None:
861                self._mirrors[handle] = state
862            reference = (
863                placeholder if placeholder is not None else self._refs.get(handle)
864            )
865            if reference is None:
866                reference = RemoteRef(self, handle, value)
867            else:
868                reference._handle = handle
869                reference._value = value
870            self._refs[handle] = reference
871            return reference
872        return value
873
874    async def _interrupt(self, call_id: str) -> None:
875        """Best-effort: tell the remote kernel this wait is over."""
876        try:
877            await self._send({"type": "interrupt", "id": call_id})
878        except Exception:
879            pass  # the channel is already gone; the caller has that error
880
881    async def invoke_release(self, handle: str) -> None:
882        """Drop a live reference; no reply waits for it."""
883        self._refs.pop(handle, None)
884        await self._send({"type": "release", "handle": handle})
885
886    async def mark_read(self, handle: str) -> None:
887        """Tell the remote object its result was read, retiring its wake.
888
889        A local mirror read never crosses the channel on its own, so this one
890        round trip (a poll, which the job counts as a read) is the only way a
891        finished remote job learns the model already has its output.
892        """
893        try:
894            await self.invoke(handle=handle, name="poll", timeout=30)
895        except Exception:
896            pass  # the channel is gone; the notice fails its own way
897
898    async def tools(self) -> dict[str, Any]:
899        """What the remote namespace holds, and the live references it kept."""
900        if self.degraded is not None:
901            return {"names": ["run"], "handles": [], "degraded": str(self.degraded)}
902        call_id = uuid.uuid4().hex
903        future: asyncio.Future[dict[str, Any]] = loop.create_future()
904        self._pending[call_id] = future
905        await self._send({"type": "introspect", "id": call_id})
906        try:
907            frame = await asyncio.wait_for(future, CONNECT_TIMEOUT)
908        finally:
909            self._pending.pop(call_id, None)
910        return {"names": frame.get("names", []), "handles": frame.get("handles", [])}
911
912    async def stop_job(self, job_id: str) -> dict[str, object]:
913        """Stop a background job running on the remote kernel."""
914        if self.closed or self.degraded is not None:
915            return {
916                "ok": False,
917                "error": "the remote kernel is closed or degraded",
918            }
919        future: asyncio.Future[dict[str, Any]] = loop.create_future()
920        self._pending[job_id] = future
921        try:
922            await self._send({"type": "job_stop", "id": job_id})
923            reply = await asyncio.wait_for(future, 15.0)
924            return {
925                "ok": reply.get("ok") is True,
926                **({"error": reply["error"]} if "error" in reply else {}),
927            }
928        except asyncio.TimeoutError:
929            return {
930                "ok": False,
931                "error": "remote job stop was not confirmed after 15s",
932            }
933        except Exception as error:
934            return {
935                "ok": False,
936                "error": str(error).lower() or "could not stop remote job",
937            }
938        finally:
939            self._pending.pop(job_id, None)
940
941    # --- ssh conveniences, both modes ---
942
943    async def read(self, path: str, *, timeout: float = COMMAND_TIMEOUT) -> str | bytes:
944        """A remote file over the control connection, not the kernel: its
945        text, or its bytes when it is not UTF-8."""
946        remote = _remote_path(self, path)
947        data = await self._ssh(f"cat {shlex.quote(remote)}", timeout)
948        try:
949            return data.decode()
950        except UnicodeDecodeError:
951            return data
952
953    async def show_image(
954        self,
955        source: bytes | str | os.PathLike[str],
956        *,
957        timeout: float = COMMAND_TIMEOUT,
958    ) -> str:
959        """Return a remote image to yourself with this cell's result, under the
960        same limits as local show_image. The remote kernel has no cell of its
961        own to carry one, so the bytes come here over the control connection."""
962        if attach_image is None:
963            raise RemoteError("this kernel cannot attach images")
964        if isinstance(source, (bytes, bytearray, memoryview)):
965            data = bytes(source)
966        elif isinstance(source, (str, os.PathLike)):
967            remote = shlex.quote(_remote_path(self, os.fspath(source)))
968            data = await self._ssh(f"head -c {IMAGE_READ} {remote}", timeout)
969        else:
970            raise TypeError(
971                f"show_image takes image bytes or a remote path, not {type(source).__name__}"
972            )
973        return "attached " + attach_image(data)
974
975    async def _ssh(
976        self, script: str, timeout: float, stdin: bytes | None = None
977    ) -> bytes:
978        code, stdout, stderr = await ssh_run(
979            self.host, in_login_shell(script), timeout=timeout, stdin=stdin
980        )
981        if code != 0:
982            raise RemoteError(f"SSH failed ({code}): {stderr.strip()}")
983        return stdout
984
985    async def write(
986        self,
987        path: str,
988        content: str | bytes | bytearray | memoryview,
989        *,
990        timeout: float = COMMAND_TIMEOUT,
991    ) -> None:
992        """Replace a remote file, text or binary. The content travels on
993        stdin, never in the command line, which a host caps (128 KiB per
994        argument on Linux)."""
995        remote = _remote_path(self, path)
996        data = content.encode() if isinstance(content, str) else bytes(content)
997        await self._ssh(f"cat > {shlex.quote(remote)}", timeout, data)
998
999    # --- degraded mode ---
1000
1001    @property
1002    def run(self) -> Any:
1003        """rem.run: the remote binding in kernel mode, command mode otherwise."""
1004        if self.degraded is None:
1005            if self.closed:
1006                raise RemoteLost(f"the connection to {self.host} is closed")
1007            return _RemoteCall(self, None, "run")
1008
1009        def run(
1010            program: object,
1011            *args: object,
1012            cwd: str | None = None,
1013            env: dict[str, object] | None = None,
1014            stdin: str | bytes | None = None,
1015            timeout: float = 300,
1016        ) -> FallbackJob:
1017            check_timeout(timeout)
1018            if not (stdin is None or isinstance(stdin, (str, bytes))):
1019                raise TypeError("in ssh command mode stdin is text or bytes")
1020            argv = albedo_shell.words((program, *args))
1021            script = albedo_shell.shell_script(argv)
1022            if script is not None:
1023                raise albedo_shell.refusal(
1024                    f"`{os.path.basename(argv[0])} -c`", script=script
1025                )
1026            return FallbackJob(self, argv, timeout, cwd=cwd, env=env, stdin=stdin)
1027
1028        return run
1029
1030    @property
1031    def shell(self) -> Any:
1032        """A shell escape hatch only when the remote kernel could not boot."""
1033        if self.degraded is None:
1034            raise AttributeError(SHELL_ONLY_DEGRADED)
1035        if self.closed:
1036            raise RemoteLost(f"the connection to {self.host} is closed")
1037
1038        def shell(
1039            script: str,
1040            *,
1041            cwd: str | None = None,
1042            env: dict[str, object] | None = None,
1043            stdin: str | bytes | None = None,
1044            timeout: float = 300,
1045        ) -> FallbackJob:
1046            if not isinstance(script, str):
1047                raise ValueError("shell script must be text")
1048            check_timeout(timeout)
1049            if not (stdin is None or isinstance(stdin, (str, bytes))):
1050                raise TypeError("in ssh command mode stdin is text or bytes")
1051            return FallbackJob(
1052                self,
1053                ["bash", "-c", script],
1054                timeout,
1055                cwd=cwd,
1056                env=env,
1057                stdin=stdin,
1058                shell=script,
1059            )
1060
1061        return shell
1062
1063    # --- surface ---
1064
1065    def __getattr__(self, name: str) -> Any:
1066        if name.startswith("_"):
1067            raise AttributeError(name)
1068        if name == "shell" and self.degraded is None:
1069            raise AttributeError(SHELL_ONLY_DEGRADED)
1070        if self.degraded is not None:
1071            raise AttributeError(
1072                f"{name!r} is unavailable: this connection is degraded to ssh command "
1073                f"mode ({self.degraded}); only rem.run(), rem.shell(), rem.read, and rem.write work"
1074            )
1075        if self.closed:
1076            raise RemoteLost(f"the connection to {self.host} is closed")
1077        return _RemoteCall(self, None, name)
1078
1079    def __repr__(self) -> str:
1080        state = (
1081            "degraded"
1082            if self.degraded is not None
1083            else ("closed" if self.closed else "kernel")
1084        )
1085        return f"<remote {self.host} ({state})>"
1086
1087    async def close(self) -> None:
1088        """End the kernel and its channel: shutdown frame, then the signal ladder."""
1089        if self.closed and self._process is None:
1090            return
1091        if self._process is not None and self._process.returncode is None:
1092            try:
1093                await self._send({"type": "shutdown"})
1094            except Exception:
1095                pass
1096            try:
1097                await asyncio.wait_for(self._process.wait(), 1.0)
1098            except asyncio.TimeoutError:
1099                self._process.terminate()
1100                try:
1101                    await asyncio.wait_for(self._process.wait(), 1.0)
1102                except asyncio.TimeoutError:
1103                    self._process.kill()
1104        self.closed = True
1105        self._live.clear()
1106        _report_live()
1107        for future in self._pending.values():
1108            if not future.done():
1109                future.set_exception(
1110                    RemoteLost(f"the connection to {self.host} closed")
1111                )
1112        self._pending.clear()
1113        for task in (self._reader, self._drain):
1114            if task is not None:
1115                task.cancel()
1116        if self in connections:
1117            connections.remove(self)
1118
1119    async def __aenter__(self) -> "RemoteConnection":
1120        return self
1121
1122    async def __aexit__(self, *_: Any) -> None:
1123        await self.close()
1124
1125    @property
1126    def host(self) -> str:
1127        return str(self.target["host"])
1128
1129
1130def _encode_arg(value: Any) -> Any:
1131    """References pass as references; everything else must already be wire data."""
1132    if isinstance(value, RemoteRef):
1133        if value._connection.closed:
1134            raise RemoteLost("cannot pass a reference from a closed connection")
1135        if value._handle is not None:
1136            return {"__ref__": value._handle}
1137        if value._task is not None:
1138            return {"pending": value._call_id}
1139        if value._settled is not _UNSET:
1140            return _encode_arg(value._settled)
1141        raise RemoteError("cannot pass an unusable remote reference")
1142    if isinstance(value, (str, int, float, bool)) or value is None:
1143        return value
1144    if isinstance(value, (bytes, bytearray)):
1145        return {"__bytes__": base64.b64encode(bytes(value)).decode("ascii")}
1146    if isinstance(value, list):
1147        return [_encode_arg(item) for item in value]
1148    if isinstance(value, dict) and type(value) is dict:
1149        return {key: _encode_arg(item) for key, item in value.items()}
1150    try:
1151        json.dumps(value)
1152        return value
1153    except (TypeError, ValueError) as error:
1154        raise RemoteError(
1155            f"cannot pass {type(value).__name__} to a remote call: only wire data "
1156            "and remote references cross"
1157        ) from error
1158
1159
1160def _remote_path(connection: RemoteConnection, path: str) -> str:
1161    """The remote spelling of a workspace path; anything else passes through."""
1162    remote_cwd = connection.target.get("remote_cwd")
1163    if remote_cwd and path.startswith(str(Path.cwd())):
1164        return str(Path(remote_cwd) / Path(path).relative_to(Path.cwd()))
1165    return path
1166
1167
1168class FallbackJob(JobOutput):
1169    """One command over the control connection when no kernel could boot.
1170
1171    The same shape as a run job: await it, tail() it, stop() it. Killing the
1172    ssh client cannot stop the remote side, so a deadline reports timed_out
1173    and says the remote effect is unknown rather than ended.
1174    """
1175
1176    def __init__(
1177        self,
1178        connection: RemoteConnection,
1179        argv: list[str],
1180        timeout: float,
1181        *,
1182        cwd: str | None = None,
1183        env: dict[str, object] | None = None,
1184        stdin: str | bytes | None = None,
1185        shell: str | None = None,
1186    ) -> None:
1187        self.id = uuid.uuid4().hex
1188        self.argv = argv
1189        self.command = shell if shell is not None else shlex.join(argv)
1190        self.timed_out = False
1191        self.exit_code: int | None = None
1192        self.started: float = loop.time()
1193        self.duration: float | None = None
1194        self.capture = capture_factory(self.id)
1195        self._awaited = False  # an await returned this job; its result reached them
1196        self._waiters = 0  # awaits still waiting; one cut short leaves the wake owed
1197        self._read = False
1198        self._announcer: asyncio.Task[None] | None = None
1199        self._connection = connection
1200        _fallback_live[self.id] = _live_job(self.id, self.command, False)
1201        _fallback_jobs[self.id] = self
1202        _report_live()
1203        # the remote login shell reads this line; everything in it is quoted
1204        steps = [
1205            f"cd {shlex.quote(str(where))}"
1206            for where in (connection.target.get("remote_cwd"), cwd)
1207            if where
1208        ]
1209        assignments = [f"{name}={value}" for name, value in (env or {}).items()]
1210        steps.append(
1211            shlex.join(["exec", *(["env", *assignments] if assignments else []), *argv])
1212        )
1213        data = stdin.encode() if isinstance(stdin, str) else stdin
1214        self._task = loop.create_task(
1215            self._run(
1216                connection.host, in_login_shell(" && ".join(steps)), timeout, data
1217            )
1218        )
1219
1220    async def _run(
1221        self, host: str, script: str, timeout: float, stdin: bytes | None
1222    ) -> "FallbackJob":
1223        process = await asyncio.create_subprocess_exec(
1224            *ssh_base(host),
1225            host,
1226            script,
1227            stdin=asyncio.subprocess.DEVNULL
1228            if stdin is None
1229            else asyncio.subprocess.PIPE,
1230            stdout=asyncio.subprocess.PIPE,
1231            stderr=asyncio.subprocess.STDOUT,
1232            env=ssh_env(host),
1233        )
1234        self._process = process
1235        if stdin is not None and process.stdin is not None:
1236            process.stdin.write(stdin)
1237            process.stdin.close()
1238        copying = loop.create_task(self._copy(process.stdout))
1239        try:
1240            self.exit_code = await asyncio.wait_for(process.wait(), timeout)
1241            with contextlib.suppress(asyncio.TimeoutError):
1242                await asyncio.wait_for(copying, timeout=1.0)
1243        except asyncio.TimeoutError:
1244            self.timed_out = True
1245            process.kill()
1246            self.exit_code = await process.wait()
1247            self.capture.write(
1248                f"\n[deadline exceeded after {timeout:g}s; the ssh client was killed; "
1249                f"the remote command may still be running]"
1250            )
1251        finally:
1252            copying.cancel()
1253            self.capture.end_spill()
1254            self.duration = loop.time() - self.started
1255            _fallback_live.pop(self.id, None)
1256            _fallback_jobs.pop(self.id, None)
1257            _report_live()
1258            self._owe_unread()
1259        return self
1260
1261    @property
1262    def _unreported(self) -> bool:
1263        """Finished, and its result has reached no one yet."""
1264        return not self._read and not self._awaited and not self._waiters
1265
1266    def _owe_unread(self) -> None:
1267        """Start the finished command's wake unless its result reached someone."""
1268        if host_call is None or not self._unreported:
1269            return
1270        if self._announcer is None or self._announcer.done():
1271            self._announcer = loop.create_task(self._announce())
1272
1273    async def _announce(self) -> None:
1274        """Wake the session for this command, retrying while it runs."""
1275        while self._unreported:
1276            try:
1277                await host_call("jobs.completed", self._notice())
1278                return
1279            except HostErrorType as error:
1280                if getattr(error, "code", "") != "busy":
1281                    self.capture.write(f"\n[completion notice not delivered: {error}\n")
1282                    return
1283            except Exception:
1284                return  # the kernel is going away; the result stays on the handle
1285            await asyncio.sleep(2.0)
1286
1287    def _notice(self) -> dict[str, object]:
1288        """The wake turn's display text, model text, and the facts behind both."""
1289        command = self.command[:200] + ("..." if len(self.command) > 200 else "")
1290        outcome = (
1291            "timed out"
1292            if self.timed_out
1293            else f"exit_code={self.exit_code}"
1294            if self.exit_code is not None
1295            else "exit status unknown"
1296        )
1297        seconds = (
1298            f"{self.duration:.1f}s" if self.duration is not None else "unknown duration"
1299        )
1300        display = (
1301            f"job finished on {self._connection.host} ({outcome}, {seconds}): {command}"
1302        )
1303        text = (
1304            "<system-note>a background command finished with its result unread on"
1305            f" {self._connection.host} (ssh command mode): {outcome}, {seconds},"
1306            f" command: {command}."
1307            " its handle is the job rem.run returned; its .tail() or"
1308            f" output.read({self.id!r}) reads its output."
1309            " no user sent this message; use the result if the session's work needs"
1310            " it, otherwise acknowledge briefly and stay idle.</system-note>"
1311        )
1312        return {
1313            "display": display,
1314            "text": text,
1315            "id": self.id,
1316            "exit_code": self.exit_code,
1317            "timed_out": self.timed_out,
1318            "duration": self.duration,
1319            "host": self._connection.host,
1320        }
1321
1322    async def _copy(self, stream: Any) -> None:
1323        while chunk := await stream.read(8192):
1324            self.capture.write_bytes(chunk)
1325
1326    def __await__(self):
1327        return self._wait().__await__()
1328
1329    async def _wait(self) -> "FallbackJob":
1330        """Wait for the command; an await cut short leaves its wake owed."""
1331        self._waiters += 1
1332        try:
1333            await asyncio.shield(self._task)
1334            self._awaited = True
1335        finally:
1336            self._waiters -= 1
1337            if self._task.done():
1338                self._owe_unread()
1339        return self
1340
1341    def poll(self) -> int | None:
1342        if self.exit_code is not None:
1343            self._read = True
1344        return self.exit_code
1345
1346    @property
1347    def returncode(self) -> int | None:
1348        """`exit_code` under subprocess's name; both spellings answer."""
1349        return self.poll()
1350
1351    async def stop(self) -> None:
1352        """End the ssh client; the remote command's fate is reported, not assumed."""
1353        self._read = True  # an explicit stop is its own report; no wake is owed
1354        # A stop can arrive before the spawn produced a process, but a spawn
1355        # that never does must not spin here forever.
1356        for _ in range(500):
1357            if getattr(self, "_process", None) is not None or self._task.done():
1358                break
1359            await asyncio.sleep(0.01)
1360        process = getattr(self, "_process", None)
1361        if process is not None and process.returncode is None:
1362            process.terminate()
1363            try:
1364                await asyncio.wait_for(process.wait(), 2.0)
1365            except asyncio.TimeoutError:
1366                process.kill()
1367            self.capture.write(
1368                "\n[stopped locally; the remote command may still be running]"
1369            )
1370        if hasattr(self, "_task"):
1371            with contextlib.suppress(Exception):
1372                await self._task
1373
1374    def __repr__(self) -> str:
1375        return (
1376            f"FallbackJob(id={self.id!r}, exit_code={self.exit_code!r}, "
1377            f"timed_out={self.timed_out!r}, duration={self.duration!r}, "
1378            f"bytes={self.capture.seen})"
1379        )
1380
1381
1382def default_modules() -> list[str]:
1383    """The session's tool selection, minus this plugin: what boots remotely."""
1384    return [name for name in session_modules if name != "remote"]
1385
1386
1387class Remote:
1388    """The namespace bound into the kernel as `remote`."""
1389
1390    @staticmethod
1391    def configure(host: str, remote_cwd: str | None = None) -> dict[str, str | None]:
1392        """Resolve `user@host[:/path]` once and keep it for later connections."""
1393        target = parse_target(host)
1394        if remote_cwd is not None:
1395            target["remote_cwd"] = remote_cwd
1396        configured.clear()
1397        configured.update(target)
1398        return dict(target)
1399
1400    @staticmethod
1401    async def connect(
1402        host: str | None = None,
1403        *,
1404        remote_cwd: str | None = None,
1405        python: str | None = None,
1406        modules: list[str] | None = None,
1407        timeout: float = HANDSHAKE_TIMEOUT,
1408    ) -> RemoteConnection:
1409        """Boot this session's kernel on a remote host, or degrade to commands.
1410
1411        The target resolves per call: an explicit `host=`, then the value
1412        `remote.configure` stored, then `$ALBEDO_SSH`, then the `remote`
1413        section of extensions.json. If the host is reachable but the kernel
1414        cannot boot, the connection still returns, prints a warning, and
1415        answers `rem.run()`, degraded-only `rem.shell()`, `rem.read`, `rem.write`,
1416        and `rem.show_image`.
1417        """
1418        target = resolve(host, remote_cwd, python)
1419        code, _, stderr = await ssh_run(
1420            str(target["host"]), "true", timeout=CONNECT_TIMEOUT
1421        )
1422        if code != 0:
1423            raise RemoteError(refusal(str(target["host"]), stderr))
1424        if target["remote_cwd"] is None:
1425            code, stdout, _ = await ssh_run(
1426                str(target["host"]), "pwd", timeout=CONNECT_TIMEOUT
1427            )
1428            if code == 0:
1429                target["remote_cwd"] = stdout.decode(errors="replace").strip() or None
1430        connection = RemoteConnection(target)
1431        try:
1432            await connection.boot(modules, timeout)
1433        except RemoteBootError as error:
1434            connection.degraded = error
1435            if connection._process is not None:
1436                if connection._reader is not None:
1437                    connection._reader.cancel()
1438                    with contextlib.suppress(asyncio.CancelledError):
1439                        await connection._reader
1440                if connection._drain is not None:
1441                    connection._drain.cancel()
1442                if connection._process.returncode is None:
1443                    connection._process.terminate()
1444            connection._process = None
1445            connection._stdin = connection._stdout = None
1446            connection.closed = False
1447            print(
1448                f"[remote] kernel boot failed on {target['host']}: {error}; "
1449                "degraded to ssh command mode: rem.run(program, *args) still works; "
1450                "for pipes or shell syntax use rem.shell('git log --oneline | rg fix') "
1451                "(bash on the host; no remote process supervision)"
1452            )
1453        connections.append(connection)
1454        return connection
1455
1456    @staticmethod
1457    def connections() -> ReadyList:
1458        """Every open connection, kernel or degraded."""
1459        return ReadyList(connections)
1460
1461    @staticmethod
1462    async def close_all() -> None:
1463        """Kernel shutdown: end every connection inside the cleanup deadline."""
1464        for connection in list(connections):
1465            await connection.close()
1466
1467
1468remote = Remote()
1469
1470
1471async def stop_remote_job(job_id: str) -> dict[str, object] | None:
1472    """Stop a remote background job across open remote connections and fallbacks."""
1473    fallback = _fallback_jobs.get(job_id)
1474    if fallback is not None:
1475        await fallback.stop()
1476        return {"ok": True}
1477    for connection in connections:
1478        if job_id in connection._live:
1479            return await connection.stop_job(job_id)
1480    return None
1481
1482
1483def setup(api: PythonApi) -> dict[str, object]:
1484    global loop, host_call, HostErrorType, capture_factory, send_frame
1485    global attach_image, session_modules
1486    loop, host_call = api.loop, api.host
1487    HostErrorType, capture_factory = api.HostError, api.capture
1488    send_frame = api.send
1489    attach_image = api.attach_image
1490    session_modules = list(api.modules)
1491    api.on_shutdown(Remote.close_all)
1492    if api.on_stop_job is not None:
1493        api.on_stop_job(stop_remote_job)
1494    return {"remote": remote, "RemoteError": RemoteError}