diff --git a/src/agents/terminal.py b/src/agents/terminal.py
index 8bc302eb0..e98197b9f 100644
--- a/src/agents/terminal.py
+++ b/src/agents/terminal.py
@@ -8,12 +8,17 @@ from engine.sessions import ACTIVE_ENV, Sessions, hold_build
 from engine import runtime
 from engine.stored import read_json, write_json
 from engine.package import CODE, code, entry_in
+from engine.extension import Extension
 from engine.fields import Loaded
+from engine.record import Record
+from resources.base import Refused
 from engine.worktree import checkout, environment, share_journal
 from typing import TypedDict
 
 from supervisor import LAUNCHED
 
+AT_ONCE = Extension()
+
 LAUNCH = 2
 CARRIED = "AGENT_JOURNAL_CARRIED"
 LAUNCH_ARGS: list = []
@@ -164,7 +169,23 @@ def launch_log(root: Path, env: str) -> Path:
     return runtime.folder(root) / "launches" / f"{env}.log"
 
 
+class TooManyAgents(Refused):
+    @classmethod
+    def running(cls, count: int) -> "TooManyAgents":
+        return cls(f"{count} agents already run here, the most this journal starts at once: stop one first, or raise the number in Settings")
+
+
+def refuse_past_cap(root: Path) -> None:
+    """A feature may cap the agents running at once, such as on a server with its own memory and spend."""
+    record = Record(root, runtime.env(root))
+    caps = [cap(record) for cap in AT_ONCE.each(record)]
+    running = len(Sessions(root).running())
+    if caps and running >= min(caps):
+        raise TooManyAgents.running(running)
+
+
 def detached(root: Path, cwd: Path, env: str, agent: str, args: list[str], conversation: str = "") -> int:
+    refuse_past_cap(root)
     started = entry_in(root, "supervisor")
     log = launch_log(root, env)
     log.parent.mkdir(parents=True, exist_ok=True)
diff --git a/src/commands/queries.py b/src/commands/queries.py
index 103ea4a4f..520014123 100644
--- a/src/commands/queries.py
+++ b/src/commands/queries.py
@@ -164,7 +164,7 @@ def supervise(ctx, agent: str) -> str:
 def ended(ctx) -> str:
     from engine.sessions import Sessions
     from features.clean_slate.slate import put_back
-    from engine.stop import ask
+    from engine.stop import ask, stays_up
     from engine.typist import live
     root = ctx["record"].root
     folder = runtime.folder(root)
@@ -174,7 +174,7 @@ def ended(ctx) -> str:
         try:
             put_back(ctx["record"])
             kept_work(Path.cwd())
-            if not live(root) and not Sessions(root).running():
+            if not live(root) and not Sessions(root).running() and not stays_up(ctx["record"]):
                 ask(root)
         finally:
             faulthandler.cancel_dump_traceback_later()
diff --git a/src/engine/disk.py b/src/engine/disk.py
index 76be325ab..ab6c8503e 100644
--- a/src/engine/disk.py
+++ b/src/engine/disk.py
@@ -44,11 +44,17 @@ def read_json(path: Path, into: Callable[[Any], T], default: T) -> T:
         return default
 
 
-def replace(path: Path, raw: bytes) -> None:
+def replace(path: Path, raw: bytes, mode: int = 0o666) -> None:
+    """Writes the whole file or nothing: a failed write, such as on a full disk, leaves the old file as it was."""
     path.parent.mkdir(parents=True, exist_ok=True)
     spare = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}")
-    spare.write_bytes(raw)
-    os.replace(spare, path)
+    try:
+        with os.fdopen(os.open(spare, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode), "wb") as written:
+            written.write(raw)
+        os.replace(spare, path)
+    except OSError:
+        spare.unlink(missing_ok=True)
+        raise
 
 
 def last_lines(path, lines: int) -> str:
diff --git a/src/engine/stop.py b/src/engine/stop.py
index 24a335c5c..d5622804a 100644
--- a/src/engine/stop.py
+++ b/src/engine/stop.py
@@ -4,11 +4,13 @@ import time
 from contextlib import suppress
 from pathlib import Path
 from engine import runtime
+from engine.extension import Extension
 from engine.proc import ran
 from engine.sessions import alive
 from engine.stored import write_text
 from engine.viewer import last, running
 
+STAYS_UP = Extension()
 WAIT = 15.0
 EVERY = 0.2
 ESCALATE = 5.0
@@ -77,3 +79,8 @@ def serving(root: Path) -> int:
     listed = ran(["ps", "-o", "command=", "-p", str(pid)]) if pid else None
     command = listed.stdout if listed else ""
     return pid if " serve" in command and any(form in command for form in (str(root), str(root.resolve()))) else 0
+
+
+def stays_up(record) -> bool:
+    """A journal whose server outlives its last agent, such as one on a server of its own."""
+    return any(keep(record.root) for keep in STAYS_UP.each(record))
diff --git a/src/features/phone/desktop.py b/src/features/phone/desktop.py
index c87c10887..8bd3c53e4 100644
--- a/src/features/phone/desktop.py
+++ b/src/features/phone/desktop.py
@@ -11,6 +11,7 @@ KEPT = ("Content-Type", "Cache-Control")
 STREAMED = "text/event-stream"
 WAIT_SECONDS = 600
 CHUNK = 65536
+API = "/api/"
 
 
 def encoded(asked) -> str:
@@ -19,42 +20,46 @@ def encoded(asked) -> str:
     return f"{path}?{urlencode(parse_qsl(asked.query, keep_blank_values=True))}" if asked.query else path
 
 
+def phone_marks(environment: str, unlocked: bool) -> dict:
+    return {PHONE_ENVIRONMENT: environment, PHONE_UNLOCKED: "1" if unlocked else "0"}
+
+
 class Desktop:
-    """The desktop viewer's /api on this computer, reached for a paired phone in its own environment."""
+    """The journal's own server on this machine, reached from outside through the share server with its loopback Host and no Origin."""
 
-    def __init__(self, handler, environment: str, unlocked: bool) -> None:
+    def __init__(self, handler, path: str, marks: dict, page_headers: dict = APP_HEADERS) -> None:
         self.handler = handler
-        self.environment = environment
-        self.unlocked = unlocked
+        self.asked = urlsplit(path)
+        self.marks = marks
+        self.page_headers = page_headers
 
     def forward(self, body: bytes) -> None:
         reached = urlsplit(lately_running(self.handler.shares.record.root))
         if not reached.port:
-            return self.handler.answer(503, "the journal on your computer is not running")
-        asked = urlsplit(self.handler.path.removeprefix("/p"))
+            return self.handler.answer(503, "the journal is not running")
         connection = HTTPConnection(reached.hostname, reached.port, timeout=WAIT_SECONDS)
         try:
-            connection.request(self.handler.command, encoded(asked), body or None, self.carried())
+            connection.request(self.handler.command, encoded(self.asked), body or None, self.carried())
             reply = connection.getresponse()
             headers = {name: reply.getheader(name) for name in KEPT if reply.getheader(name)}
             kind = reply.getheader("Content-Type", "")
             if kind.startswith(STREAMED):
                 return self.stream(reply, headers)
-            if not kind.startswith(JSON):
-                headers["Content-Disposition"] = disposition(unquote(asked.path.rsplit("/", 1)[-1]))
-            return self.handler.packed(reply.status, reply.read(), {**headers, **APP_HEADERS, "X-Content-Type-Options": "nosniff"})
+            if not kind.startswith(JSON) and self.asked.path.startswith(API):
+                headers["Content-Disposition"] = disposition(unquote(self.asked.path.rsplit("/", 1)[-1]))
+            return self.handler.packed(reply.status, reply.read(), {**headers, **self.page_headers, "X-Content-Type-Options": "nosniff"})
         except (OSError, HTTPException):
-            return self.handler.answer(502, "the journal on your computer did not answer")
+            return self.handler.answer(502, "the journal did not answer")
         finally:
             connection.close()
 
     def carried(self) -> dict:
         given = {name: self.handler.headers[name] for name in CARRIED if name in self.handler.headers}
-        return {**given, PHONE_ENVIRONMENT: self.environment, PHONE_UNLOCKED: "1" if self.unlocked else "0"}
+        return {**given, **self.marks}
 
     def stream(self, reply, headers: dict) -> None:
         self.handler.send_response(reply.status)
-        for name, value in {**headers, **APP_HEADERS, "Cache-Control": "no-cache", "X-Accel-Buffering": "no"}.items():
+        for name, value in {**headers, **self.page_headers, "Cache-Control": "no-cache", "X-Accel-Buffering": "no"}.items():
             self.handler.send_header(name, value)
         self.handler.end_headers()
         while chunk := reply.read1(CHUNK):
diff --git a/src/features/phone/routes.py b/src/features/phone/routes.py
index 5c6cda1a3..640a635fc 100644
--- a/src/features/phone/routes.py
+++ b/src/features/phone/routes.py
@@ -10,14 +10,14 @@ from http.cookies import SimpleCookie
 from engine.record import Record
 from engine.fields import Loaded
 from features.phone.controller import Phones
-from features.phone.desktop import Desktop
+from features.phone.desktop import Desktop, phone_marks
 from features.phone.passkey import Assertion, Enrolment, Relying, requested
 from features.phone.surface import PhoneSurface
 from engine.color import identity
 from features.sharing.controller import Shares
 from features.sharing.page import disposition, unshared
 from features.sharing.preview import icon
-from features.sharing.server import APP_DIR, APP_HEADERS, BODY_LIMIT
+from features.sharing.server import APP_DIR, APP_HEADERS, BODY_LIMIT, local, own_origin
 from resources.base import SYSTEM, Refused, Stale
 from features.trigger import DAY
 from engine.wording import digest
@@ -33,7 +33,6 @@ HEADER = "X-Phone"
 UNLOCK = "X-Phone-Unlock"
 UPLOAD_LIMIT = 25 * 1024 * 1024
 UPLOADED = "application/octet-stream"
-LOCAL = ("127.0.0.1", "localhost")
 BUILD = re.compile(r"assets/(phone-[\w-]+\.js)")
 
 
@@ -214,10 +213,6 @@ def built() -> str:
     return found.group(1) if found else ""
 
 
-def local(host: str) -> bool:
-    return host.split(":", 1)[0] in LOCAL
-
-
 class PhoneRoutes:
     def get(self, handler, rest: list[str]) -> None:
         if rest[:1] == ["assets"] and len(rest) == 2:
@@ -350,7 +345,7 @@ class PhoneRoutes:
             return None
         body = handler.rfile.read(size) if size else b""
         unlocked = self.phones(handler)._spend(phone, handler.headers.get(UNLOCK, ""), requested(handler.command, handler.path, body))
-        return Desktop(handler, phone.environment, unlocked).forward(body)
+        return Desktop(handler, handler.path.removeprefix("/p"), phone_marks(phone.environment, unlocked)).forward(body)
 
     def attach(self, handler, rest: list[str]) -> None:
         if not self.trusted(handler, UPLOADED):
@@ -396,9 +391,7 @@ class PhoneRoutes:
         return Relying(address, f"https://{address}")
 
     def trusted(self, handler, kind: str = "application/json") -> bool:
-        host = handler.headers.get("Host", "")
-        origin = f"{'http' if local(host) else 'https'}://{host}"
-        return (handler.headers.get("Origin") == origin and handler.headers.get(HEADER) == "1"
+        return (handler.headers.get("Origin") == own_origin(handler.headers) and handler.headers.get(HEADER) == "1"
                 and handler.headers.get("Content-Type", "").startswith(kind))
 
     def body(self, handler) -> dict | None:
diff --git a/src/features/sharing/address.py b/src/features/sharing/address.py
index 6fc033806..1a59f7fff 100644
--- a/src/features/sharing/address.py
+++ b/src/features/sharing/address.py
@@ -7,6 +7,7 @@ from engine.record import Record
 from engine.viewer import machine
 
 RELIED_ON = Extension()
+ANSWERS_AT = Extension()
 MACHINE_FILE = "machine-id"
 
 
@@ -38,3 +39,8 @@ def this_machine() -> str:
 
 def relied_on(record: Record) -> bool:
     return any(depends(record.root) for depends in RELIED_ON.each(record))
+
+
+def own_address(record: Record) -> list[str]:
+    """The address a feature gives the journal in place of a tunnel, such as the domain of a journal on a server."""
+    return [found for given in ANSWERS_AT.each(record) if (found := given(record))]
diff --git a/src/features/sharing/controller.py b/src/features/sharing/controller.py
index f42338584..8bdf6eb9d 100644
--- a/src/features/sharing/controller.py
+++ b/src/features/sharing/controller.py
@@ -18,7 +18,7 @@ from features.sharing.page_data import SharePages
 from features.sharing.passwords import hashed
 from features.sharing.resource import SHARED_TYPES, Share
 from engine.services import DOWN, FAILED, UP, log_file, status, want
-from features.sharing.address import Claim, relied_on, this_machine
+from features.sharing.address import Claim, own_address, relied_on, this_machine
 from features.sharing.tunnel import ADDRESS_REFUSED, DEFAULT_SERVER, KEPT_STATUS, READDRESSED, SIGNED_OUT, TUNNEL, TunlerVersion, TunnelStatus, addressed, alerts, install, keep_address, kept_address, last_lines, refused_address, log_in, log_out, moved, new_address, owned, readable_address, server_name, tunler_status, unclaim, updated, versions
 from features.sharing.visiting import ShareVisits, sharing_feature
 from features.sharing.visitors import AGREEMENT, unhold, unindex_comment
@@ -177,6 +177,8 @@ class Shares(ShareVisits, SharePages, Controller):
     @action
     def tunnel(self) -> dict:
         standing = {**tunler_status(), "server": self._host() or DEFAULT_SERVER}
+        if own := own_address(self.record):
+            return {**standing, "address": own[0], "problems": []}
         try:
             return {**standing, "address": self._address(), "problems": self._problems(standing)}
         except Refused as unreadable:
@@ -229,6 +231,8 @@ class Shares(ShareVisits, SharePages, Controller):
         return []
 
     def _address(self) -> str:
+        if own := own_address(self.record):
+            return own[0]
         host = self._host()
         return f"{self._subdomain()}.{host}" if host else ""
 
diff --git a/src/features/sharing/routes.py b/src/features/sharing/routes.py
index f5143af2a..bcb2e81f3 100644
--- a/src/features/sharing/routes.py
+++ b/src/features/sharing/routes.py
@@ -2,3 +2,4 @@ from engine.extension import Extension
 
 ROUTES = Extension()
 TICKS = Extension()
+EVERY_OTHER = "*"
diff --git a/src/features/sharing/server.py b/src/features/sharing/server.py
index bc0d61cf6..4aebe018c 100644
--- a/src/features/sharing/server.py
+++ b/src/features/sharing/server.py
@@ -14,11 +14,12 @@ from engine.package import data  # noqa: E402
 from features import running  # noqa: E402
 from features.sharing.controller import HEALTH, HEALTH_MARKER, LAYOUT_FILE  # noqa: E402
 from features.sharing.passwords import unlocked  # noqa: E402
+from features.sharing.services import LOOPBACK  # noqa: E402
 from features.sharing.visiting import SharedComment  # noqa: E402
 from features.sharing.page import PICTURES, Page, disposition, document, unshared  # noqa: E402
 from features.format import SHARED, formatted
 from features.sharing.preview import card, tags  # noqa: E402
-from features.sharing.routes import ROUTES, TICKS  # noqa: E402
+from features.sharing.routes import EVERY_OTHER, ROUTES, TICKS  # noqa: E402
 from controllers.faults import threw  # noqa: E402
 from engine.color import identity  # noqa: E402
 from resources.base import Refused  # noqa: E402
@@ -33,6 +34,8 @@ PACKED_FROM = 1024
 PACKED = ("text/", "application/javascript", "image/svg+xml")
 COMMENT_HEADER = "X-Shared-Comment"
 APP_PAGE = "share.html"
+OWN_PATHS = ("s", HEALTH)
+LOCAL = ("127.0.0.1", "localhost")
 PREVIEW = "preview.png"
 APP_HEADERS = {"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
                                           "font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"}
@@ -47,8 +50,22 @@ HEADERS = {
 }
 
 
+def local(host: str) -> bool:
+    return host.split(":", 1)[0] in LOCAL
+
+
+def own_origin(headers) -> str:
+    """The origin this server's own pages load from: https behind a TLS proxy or a tunnel, plain http only on the machine itself."""
+    host = headers.get("Host", "")
+    secure = headers.get("X-Forwarded-Proto") == "https" or not local(host)
+    return f"{'https' if secure else 'http'}://{host}"
+
+
 def routed(parts: list[str], record):
-    return ROUTES.keyed(record).get(parts[0]) if parts else None
+    """The feature route a path's first part names, or else the one that takes every path the share server does not answer itself."""
+    keyed = ROUTES.keyed(record)
+    first = parts[0] if parts else ""
+    return keyed.get(first) or (None if first in OWN_PATHS else keyed.get(EVERY_OTHER))
 
 
 @dataclass(frozen=True)
@@ -261,12 +278,12 @@ def ticking(shares, stopped: threading.Event) -> None:
                 threw(shares.record.root, shares.record.env, f"a share server tick: {getattr(tick, '__name__', tick)}")
 
 
-def serve(shares, port: int) -> None:
+def serve(shares, port: int, host: str = LOOPBACK) -> None:
     stopped = threading.Event()
     threading.Thread(target=ticking, args=(shares, stopped), daemon=True).start()
     handler = type("BoundShareHandler", (ShareHandler,), {"shares": shares, "timeout": READ_SECONDS})
     try:
-        with ThreadingHTTPServer(("127.0.0.1", int(port)), handler) as server:
+        with ThreadingHTTPServer((host, int(port)), handler) as server:
             server.serve_forever()
     finally:
         stopped.set()
@@ -282,7 +299,7 @@ def main(argv: list[str]) -> None:
     root = Path(argv[0])
     features.load(root)
     watch_change_log()
-    serve(Shares(Record(root, runtime.env(root)), actor=SYSTEM), int(argv[1]))
+    serve(Shares(Record(root, runtime.env(root)), actor=SYSTEM), int(argv[1]), *argv[2:3])
 
 
 if __name__ == "__main__":
diff --git a/src/features/sharing/services.py b/src/features/sharing/services.py
index 8872f87e5..bab357132 100644
--- a/src/features/sharing/services.py
+++ b/src/features/sharing/services.py
@@ -1,3 +1,4 @@
+from dataclasses import dataclass
 from pathlib import Path
 
 from engine import runtime
@@ -12,6 +13,8 @@ from resources.base import Refused, SYSTEM
 from engine.extension import Extension
 
 KEEP_UP = Extension()
+LISTENS = Extension()
+LOOPBACK = "127.0.0.1"
 IDLE = "Nothing is shared and no phone is paired, so the tunnel stays off."
 SWITCHED_OFF = "Sharing is switched off, so the tunnel stays off."
 
@@ -43,11 +46,26 @@ def wanted(root: Path) -> bool:
     return not why_idle(root)
 
 
+@dataclass(frozen=True)
+class Listen:
+    """Where the share server listens: on this machine alone, at a port it is given or one it finds."""
+
+    host: str = LOOPBACK
+    port: int | None = None
+
+
+def listen_place(root: Path) -> Listen:
+    record = Record(root, runtime.env(root))
+    placed = [given(record) for given in LISTENS.each(record)]
+    return placed[0] if placed else Listen()
+
+
 def share_services(root: Path, taken: set) -> list:
     idle = why_idle(root)
-    port, blocked = allocate(root, SERVER, None, taken)
+    place = listen_place(root)
+    port, blocked = allocate(root, SERVER, place.port, taken)
     taken.add(port)
-    specs = [ServiceSpec(id=SERVER, plugin="sharing", service="server", run=[*entry("features.sharing.server"), str(root), str(port)],
+    specs = [ServiceSpec(id=SERVER, plugin="sharing", service="server", run=[*entry("features.sharing.server"), str(root), str(port), place.host],
                          cwd=str(Path(root).parent), port=port, blocked=blocked, idle=idle, url=f"http://127.0.0.1:{port}", env={BUILD: current_build(root)},
                          **files_for(root, SERVER))]
     command = tunler()
diff --git a/src/serve.py b/src/serve.py
index 6c998fc83..e06a0e74d 100644
--- a/src/serve.py
+++ b/src/serve.py
@@ -188,6 +188,25 @@ def freeze_caches(halting: threading.Event) -> None:
         gc.freeze()
 
 
+def keep_services(root: Path, halting: threading.Event) -> None:
+    """A journal that stays up with no agent, such as one on a server, keeps its services up from here."""
+    from agents.terminal import lifeline
+    from controllers.faults import threw
+    from engine.record import Record
+    from engine.services import Manager
+    from engine.stop import stays_up
+    from features.plugins.services import plugin_services
+    if not stays_up(Record(root, default_env(root))):
+        return
+    alive, _keeping = lifeline()
+    manager = Manager(root, alive, sources=(plugin_services,), faulted=lambda where: threw(root, default_env(root), where))
+    while not halting.wait(WATCH_SECONDS):
+        try:
+            manager.tick()
+        except Exception:
+            threw(root, default_env(root), "the server's services")
+
+
 def warm_commands() -> None:
     from commands.cli import served
     from commands.parser import parser
@@ -237,6 +256,7 @@ def run(root: Path, port: int = DEFAULT_PORT) -> None:
     threading.Thread(target=replay, args=(root,), daemon=True).start()
     threading.Thread(target=warm, args=(root,), daemon=True).start()
     threading.Thread(target=warm_commands, daemon=True).start()
+    threading.Thread(target=keep_services, args=(root, halting), daemon=True).start()
     try:
         server.serve_forever()
     except KeyboardInterrupt:
