Skip to content

Feature: the control surface for supervised operation #5533

Description

@M-Maciej

Feature: per-session control socket (message / interrupt / relaunch / status) and RuntimeBackendKind::External — the control surface for supervised operation

Problem

I run codewhale sessions under external supervision — a supervisor (a
terminal multiplexer wrapper, an automation harness, a CI-style setup)
watching long-lived sessions with no human at the screen. Two things
work today: observation and re-creation. The lifecycle outbox (see the
lifecycle-outbox feature issue) gives the supervisor a machine-readable
stream of what happened, and the restart/resume backend (see the
/relaunch feature issue) lets it re-create a stopped Lane from its
durable record. What still does not work is everything in between:
commanding a session that is running.

Today the only inbound channel is terminal simulation. Delivering a
message means typing text plus Enter into the pane's PTY — no
acknowledgement that the session consumed it, no structured payload, and
the bytes can interleave with whatever the agent itself is typing.
Cancelling a wedged turn means sending an Esc key. Finding out the
current turn or goal state means scraping the rendered screen. A
supervisor managing many sessions across many workspaces is driving each
one with keystrokes and inferring the results from pixels.

The control plane's own vocabulary names the gap. ControlSurface { Cli, Slash } is documented as "There are exactly two"
(crates/lane/src/control.rs:43-63), so no existing surface can be
driven by another process. The Runtime slot tells the same story one
level down: RuntimeBackendKind { Tmux, Inline, Vm, Ci }
(crates/lane/src/runtime.rs:21-25) has no kind for "execution is
delegated to an outside session manager" — the Vm/Ci stubs wait for
concrete managers, but there is no way to say "another process owns this
session's lifecycle". A supervised codewhale is neither addressable nor
a first-class Runtime fact.

Proposed solution

Two asks, one umbrella: a per-session control socket (the third
surface), and a RuntimeBackendKind::External variant whose methods are
those socket verbs (the external runtime backend).

The socket surface — a third ControlSurface

An opt-in [control_socket] config table (enabled: bool, default
false — unset or absent changes nothing). When enabled, the interactive
TUI binds one unix domain socket per running session at

<sessions-dir>/<session-id>/control.sock     (mode 0600)

(<sessions-dir> is the directory the session store already uses.) The
socket speaks newline-framed JSON-RPC, one request per connection:
connect, write one request line, read one response line, close.

{"id":"1","method":"message","params":{"text":"hello"}}
{"id":"2","method":"interrupt","params":{}}
{"id":"3","method":"relaunch","params":{}}
{"id":"4","method":"status","params":{}}

The four verbs are seams onto existing machinery; none invents a second
implementation:

  • message — delivers text as a structured user message through the
    ordinary composer dispatch path; dispatched immediately when the app
    is idle, queued when a turn is in flight (queued delivery is the
    default under load); the response's delivery field reports which
    happened. This is the typed replacement for "the supervisor types into
    the PTY".
  • interrupt — the exact Esc-shaped cancel of the active turn (the same
    cancel body the Esc key triggers, so the two paths cannot drift);
    cancelled reports whether work was in flight.
  • relaunch — the /relaunch mechanics as a verb, routed through the
    /relaunch slash-command path (a seam, not a copy of the logic).
  • status — a snapshot of the running session: turn_state (idle /
    in_progress / waiting) and goal (objective, status,
    paused), republished by the event loop every iteration — the
    session-level complement of the registry-level lane.status.

Responses echo the request id with a type-tagged result; failures are
{"id", "error": {"code", "message"}} with codes invalid_request,
command_error, timeout, server_unavailable. Requests are bounded
(1 MiB per line) and a handler that does not answer within 5 s is
reported as timeout. Verbs that must not run on the composer thread
reuse the off-loop queued-receipt pattern lane.interrupt already
established (control.rs:595, and the slash surface's ticket receipt in
crates/tui/src/commands/groups/core/lane.rs:44-58).

The socket lifecycle rides the session lifecycle: the socket lives
inside the per-session artifact directory, so session deletion and the
orphan-reclaim sweep remove it; a stale socket left by a crashed process
is taken over by the next bind (connect-probe, then unlink); if a live
foreign owner holds the path, the bind is refused and retries back off
instead of flooding. Authorization is the socket file itself: mode 0600
inside the per-session directory means same-machine, same-user access
only.

The umbrella — RuntimeBackendKind::External

A new variant declaring "execution is delegated to an outside session
manager". A RuntimeBackend implementation whose methods are the
surface above:

  • start — spawn the Lane inside the supervised session, discovering it
    the way LaneStartSpec already supports: the supervisor passes
    session identity through environment (never the child argv, never
    the durable record).
  • attach_command — the supervisor's attach target.
  • stop — the socket interrupt under the existing lifecycle fence
    (<lane-id>@<lifecycle-seq> — the "never stop a generation you did
    not observe" guarantee).
  • reconcile — a socket status folded into the record.
  • cleanup_worktree — the existing TTL path.

This makes "many codewhale sessions under one manager" a Runtime fact
the registry already knows how to persist: one LaneRecord per
supervised session, with runtime selecting the backend. The Vm/Ci
stubs can be read as specializations of this kind, or as peer kinds —
the ask is the generic supervised kind, not a specific manager.

Scope note: the socket surface lands first (it is what the backend's
methods call); the External backend implementation is the follow-up on
top of it. The accompanying PR ships the socket surface.

How this composes the two prior asks — and why each stays independently useful

The umbrella composes the series: observe (the lifecycle outbox, the
"up" channel: what happened, machine-readable), re-create (/relaunch,
re-creating a stopped Lane from its durable record), and now command
(this issue — the "down" channel: message, interrupt, relaunch, status
over a typed socket). Each prior ask stays useful on its own: the outbox
needs nothing else (any supervisor on any backend can consume it), and
/relaunch does not need the new kind (re-creating a stopped Lane works
for tmux lanes today). This issue's socket verbs are precisely the seams
the new kind's methods call.

Use case

When I would use this, and why keystroke simulation and per-verb CLI
invocations are not enough:

  • A supervisor managing many sessions across many workspaces:
    structured messages that are acknowledged and can never interleave
    with the agent's own typing; a cancel that is a verb with a receipt
    instead of an Esc keystroke; re-creation of a stopped Lane without a
    human at the screen; status without scraping the rendered screen.
  • Automation and CI-style harnesses: the same socket a harness can
    drive from any language — the command half of the outbox's
    observation story.
  • The many-sessions-under-one-manager deployment: with the
    External kind, that topology becomes a Runtime fact instead of a
    convention.

Alternatives considered

  1. Keystroke simulation (type text + Enter; Esc to cancel) — what
    supervision does today. No acknowledgement that a message was
    consumed, no structured payload, collisions with the agent's own
    typing, and interrupt is a key hack. This is the baseline, not a
    competitor.
  2. Shelling out to codewhale lane … / codewhale fleet …
    invocations
    — the Cli surface works for registry verbs, but every
    invocation is a fresh process, it addresses the durable registry
    rather than the live session (an in-flight turn, the composer), and
    it offers no session-scoped transport. The socket adds the session
    address the CLI deliberately lacks.
  3. Extending the hook system with control commands — hooks are the
    out-only family (the outbox's machinery). A command channel needs
    request/response pairs, receipts, timeouts, and lifecycle fences,
    none of which belong in hook sinks.
  4. Filling the Vm/Ci stubs with one concrete manager each
    hard-codes managers into kinds. A reviewer may ask "isn't this just
    tmux generalized?" — yes, and that is the point: tmux is itself an
    external supervisor, the External kind is the name for "another
    process owns the session's lifecycle", and the tmux implementation
    remains one concrete supervisor, exactly as a future VmRuntime
    would be for Vm. The ask is the trait shape, not a new mechanism.
  5. The app-server / runtime API — already covers API-driven threads
    (durable per-thread store, HTTP+SSE replay), but ordinary interactive
    sessions never populate it. The socket is the interactive-session
    surface, drawing the same TUI-only boundary RFC 1364 drew for hooks
    (app-server/ACP out of initial scope).

Impact

Every supervised deployment — this is the missing half of the workflow
for anyone running long-lived or unattended sessions. Observation
(outbox) and re-creation (restart/resume) already exist; this makes
commanding a running session a typed, receipted channel instead of
terminal simulation. Message delivery becomes acknowledged and
collision-free; turn cancellation becomes a verb; status becomes a
query. Zero cost when off: unset or enabled = false changes nothing.
The surface reuses the existing control vocabulary, descriptors,
receipts, and fences; the one genuinely new capability is structured
message injection — the typed replacement for keystroke simulation.

Additional context

  • The implementation exists and is tested: the accompanying PR
    carries crates/tui/src/tui/control_socket.rs (bind/serve/JSON-RPC
    transport, the four verbs, reconcile/update_status/drain wiring
    into the event loop), the [control_socket] config table, and a
    contract section in docs/CONFIGURATION.md. New tests cover verb
    parsing; verb execution over a live bound socket; response-shape round
    trip; protocol errors (malformed, unknown, and oversized requests,
    empty line); socket lifecycle (live-bind refusal, stale-file takeover,
    unbind-on-drop, takeover backoff); and config parsing.
  • The relaunch verb is a seam, not a copy of logic: it routes
    through the /relaunch slash command and reports that command's own
    error verbatim if it is absent, so the socket PR and the relaunch PR
    stay independently reviewable.
  • The External backend is the follow-up: the PR lands the socket
    surface only; the RuntimeBackendKind::External implementation (and
    the control-plane doc's third-surface row) rides the follow-up on top
    of it.
  • Sibling gap, one domain up: the fleet.restart surface-limited
    row (control.rs:588-589) is the same story at the Fleet level — a
    supervisor re-leasing a durable Fleet task — and can ride the same
    socket surface when its backend lands.
  • RFC 1364 precedent: hooks drew the TUI-only boundary; this surface
    draws the same one.
  • Known limitations, stated upfront:
    • Unix-only: a unix domain socket; on other platforms the config key
      parses but nothing binds.
    • Machine-local by design: authorization is the socket file's mode
      and location. Cross-machine control is deliberately out of scope for
      this ask; a relay can sit on top of the socket without changing its
      contract.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions