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
- 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.
- 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.
- 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.
- 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.
- 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.
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 bedriven 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 isdelegated to an outside session manager" — the
Vm/Cistubs wait forconcrete 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::Externalvariant whose methods arethose socket verbs (the external runtime backend).
The socket surface — a third
ControlSurfaceAn opt-in
[control_socket]config table (enabled: bool, defaultfalse — unset or absent changes nothing). When enabled, the interactive
TUI binds one unix domain socket per running session at
(
<sessions-dir>is the directory the session store already uses.) Thesocket 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— deliverstextas a structured user message through theordinary 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
deliveryfield reports whichhappened. This is the typed replacement for "the supervisor types into
the PTY".
interrupt— the exact Esc-shaped cancel of the active turn (the samecancel body the Esc key triggers, so the two paths cannot drift);
cancelledreports whether work was in flight.relaunch— the /relaunch mechanics as a verb, routed through the/relaunchslash-command path (a seam, not a copy of the logic).status— a snapshot of the running session:turn_state(idle/in_progress/waiting) andgoal(objective,status,paused), republished by the event loop every iteration — thesession-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 codesinvalid_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 threadreuse the off-loop queued-receipt pattern
lane.interruptalreadyestablished (
control.rs:595, and the slash surface's ticket receipt incrates/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::ExternalA new variant declaring "execution is delegated to an outside session
manager". A
RuntimeBackendimplementation whose methods are thesurface above:
start— spawn the Lane inside the supervised session, discovering itthe way
LaneStartSpecalready supports: the supervisor passessession identity through
environment(never the child argv, neverthe durable record).
attach_command— the supervisor's attach target.stop— the socketinterruptunder the existing lifecycle fence(
<lane-id>@<lifecycle-seq>— the "never stop a generation you didnot observe" guarantee).
reconcile— a socketstatusfolded 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
LaneRecordpersupervised session, with
runtimeselecting the backend. TheVm/Cistubs 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
Externalbackend implementation is the follow-up ontop 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:
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.
drive from any language — the command half of the outbox's
observation story.
Externalkind, that topology becomes a Runtime fact instead of aconvention.
Alternatives considered
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.
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.
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.
Vm/Cistubs 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
Externalkind is the name for "anotherprocess owns the session's lifecycle", and the tmux implementation
remains one concrete supervisor, exactly as a future
VmRuntimewould be for
Vm. The ask is the trait shape, not a new mechanism.(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 = falsechanges 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
carries
crates/tui/src/tui/control_socket.rs(bind/serve/JSON-RPCtransport, the four verbs,
reconcile/update_status/drainwiringinto the event loop), the
[control_socket]config table, and acontract section in
docs/CONFIGURATION.md. New tests cover verbparsing; 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.
relaunchverb is a seam, not a copy of logic: it routesthrough the
/relaunchslash command and reports that command's ownerror verbatim if it is absent, so the socket PR and the relaunch PR
stay independently reviewable.
Externalbackend is the follow-up: the PR lands the socketsurface only; the
RuntimeBackendKind::Externalimplementation (andthe control-plane doc's third-surface row) rides the follow-up on top
of it.
fleet.restartsurface-limitedrow (
control.rs:588-589) is the same story at the Fleet level — asupervisor re-leasing a durable Fleet task — and can ride the same
socket surface when its backend lands.
draws the same one.
parses but nothing binds.
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.