Persistent Remote Sessions
A persistent session is a long-lived shell-like context on a remote machine that survives across many tool calls within one chat turn (and beyond). It is the right model when “one shot exec” is too coarse — when you need to send a sequence of related commands, watch their output as it trickles in, and only close the session when the work is done.
The implementation lives in internal/connect/sessionmgr/ and is exposed
to agents as the connect_session tool.
When to use it
The decision tree:
- One command, finishes in seconds. Use
cmdop connect exec. - Interactive shell, human at the keyboard. Use interactive-attach.
- Many commands stitched together by an agent over many turns. Use a persistent session.
Concrete use cases:
- An agent doing a multi-step build/deploy that wants to read intermediate output before deciding the next step.
- Tailing a slow log while running diagnostic commands in the same context.
- Keeping a long-running process (compile, container build, migration)
alive across multiple
readpolls without re-establishing auth.
A persistent session is a sessionmgr object. It is not the same as a
terminal session opened by cmdop connect. The former is many commands
on one ringbuf; the latter is one PTY the user is interacting with.
See ../concepts/sessions for both kinds.
The session model in brief
A persistent session walks a small state machine
(opening → ready → busy → closing → closed), captures output in a
1 MiB ring buffer so callers poll instead of stream, and is closed by an
idle reaper after 30 minutes of inactivity (64 sessions per process). The
formal object model — state transitions, the ring buffer contract, idle
TTL, and driver injection — lives on the concept page:
Remote sessions. This page stays on the
task: when to open one and how to drive it.
The agent tool surface
connect_session exposes five operations on a session:
| Operation | Effect |
|---|---|
open | Dial a new session on a target machine. Returns a session_id. |
send | Send a command. Output goes into the ring buffer. |
read | Poll output since a given offset. Returns (data, next_cursor, truncated). |
list | List the caller’s open sessions. |
close | Close a session and free its ring buffer. |
A typical agent sequence:
{ "tool": "connect_session", "operation": "open",
"hostname": "vps-bmw", "idle_ttl_seconds": 0 }
// → { "session_id": "sess_3a2b...", "state": "ready" }
{ "tool": "connect_session", "operation": "send",
"session_id": "sess_3a2b...", "command": "make build 2>&1" }
{ "tool": "connect_session", "operation": "read",
"session_id": "sess_3a2b...", "offset": 0 }
// → { "data": "...", "next_cursor": 4096, "truncated": false }
{ "tool": "connect_session", "operation": "read",
"session_id": "sess_3a2b...", "offset": 4096 }
// → { "data": "...", "next_cursor": 8192, "truncated": false, "exit_code": 0 }
{ "tool": "connect_session", "operation": "close", "session_id": "sess_3a2b..." }Reading output without losing data
Output lands in a per-session ring buffer; you read it by offset. Each
read returns (data, next_cursor, truncated). A truncated=true means
the buffer wrapped and you missed bytes between this read and the last —
poll more often, or surface a warning. The polling pattern:
loop:
data, cursor, truncated = read(offset=cursor)
if truncated:
surface a warning ("dropped X bytes")
consume(data)
offset = cursorThe ring buffer contract (size, monotonic offsets, drop-oldest semantics) is described on the Remote sessions concept page.
Keeping a long task alive
A reaper closes sessions after 30 minutes idle. For tasks that outlive
that — long builds, log tails, big migrations — pass idle_ttl_seconds: 0
on open to disable auto-close, then close the session yourself when
done:
{ "tool": "connect_session", "operation": "open",
"hostname": "prod-api-1", "idle_ttl_seconds": 0 }Persistent sessions reserve memory (1 MiB ring buffer each by default). The cap is 64 per process; if you fan out heavily across board automations, watch the count and close sessions you no longer need.
Session IDs and concurrency
Session IDs come in two flavors — daemon-issued (host-uuid:slotN) and
desktop-minted (machine_<uuid>_<hex>, used by the
desktop inspector). A session ID is valid only
for the lifetime of the daemon process that holds it; sessions do not
survive a daemon restart. Multiple readers can read one session
concurrently (each tracks its own offset), but concurrent send is
serialized — a send that arrives while the session is busy queues.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
session not found after a long pause. | Reaper closed it on idle TTL. | Re-open; pass idle_ttl_seconds: 0 next time. |
truncated=true and you missed output. | Ring buffer wrapped between polls. | Poll more often, or accept the gap. |
manager full (64 sessions) | Too many open sessions. | Close idle ones via connect_session.close. |
| Long build hangs forever. | The remote process is genuinely stuck — sessionmgr does not enforce a per-command timeout. | Use the agent’s overall turn timeout, or close the session. |