agent-session
Overview
agent-session starts and manages tmux-backed Codex, Claude Code, and Hermes sessions for mobile handoff workflows. It is designed for
personal automation such as Hermes Telegram skills and the agent-console mobile control plane: a service can create the session with a full
prompt, then return a short tmux attach command for the user to continue from Termius, glance at the pane, or steer it with keystrokes.
Package vs binary name
| Field | Value |
|---|---|
| Package name | nils-agent-session |
| Binary name | agent-session |
Usage
|
send pushes input to a live session: literal text (--text / --text-stdin) and/or repeatable named keys
(--key enter|escape|c-c|up|down|left|right|tab), so codex/claude approval prompts are answerable from a phone.
glance returns the recent pane tail plus live status as a JSON contract for dashboard tiles (cheaper than a full attach).
resume recreates a missing tmux runtime only when the session has exact provider resume metadata; it never resumes the
latest provider conversation implicitly. Runtime metadata is persisted before launch so hooks see the new generation;
if tmux launch fails, the prior runtime and activity snapshot are restored. send bumps updated_at, so list orders
by real control-plane activity.
--agent hermes launches hermes chat interactively (one-shot run mode is codex/claude only).
Durable turn state
Every new runtime receives a fresh opaque AGENT_SESSION_RUNTIME_ID alongside
AGENT_SESSION_ID and AGENT_SESSION_STATE_DIR. Supported provider hooks
project lifecycle metadata into a private, atomic activity snapshot and bounded
journal. Provider identifiers are runtime-scoped opaque projections, attention
and replay state are explicitly bounded, and interrupted snapshot/journal
writes repair before the next event or runtime transition. State is bound to
both the launch id and persisted runtime generation so a stale snapshot is
never shown after an interrupted resume. The replay index carries the same
runtime binding and a missing or swapped index degrades safely to Unknown
instead of reopening the dedupe horizon. Session views add optional
runtime_started_at and turn_state
fields, distinguishing starting, working, waiting, needs_input, and
unknown without storing prompt, assistant, terminal, command, tool, or
transcript content.
Provider setup is explicit and reversible. Preview it first, then apply only after reviewing the provider trust/consent boundary:
The same commands support claude and hermes. Setup merges exact
agent-session-owned handlers into existing provider configuration, repeated
apply/repair is idempotent, and removal preserves unrelated hooks. Provider
setup also refuses an observed concurrent config change. Provider hook failure
is fail-open and old/unsupported providers retain the activity
fallback. Doctor scans local session evidence once and probes provider versions
concurrently with a bounded timeout, verifies the exact owned hook timeout, and
checks that the configured helper resolves to an executable on the hook PATH.
See the stable turn-state contract and
provider evidence matrix.
Serve daemon
agent-session serve exposes the session control plane over loopback HTTP for a per-machine edge (e.g. the agent-console
web console). It builds its own tokio runtime and reuses the synchronous lifecycle functions via spawn_blocking, so there
is no second state model.
-
GET /healthz,GET /sessions,GET /sessions/{id}/glance?tail=N— reads, open on loopback. Sessions reportrunning,stopped, orunknownlive status plus a booleanresumablefield and best-effortrepo_namederived from the recordedcwd. New records also expose optionalruntime_started_atandturn_state; old records omit them. -
GET /usage— read-only provider usage report, open on loopback. The serve envelope containsdata.usage.schema_version: "agent-session.usage.v1"and provider entries for Codex and Claude. Provider readers are bounded byAGENT_SESSION_USAGE_TIMEOUT_MS(default 12000), preserve partial success, preserve reset timestamps asreset_at_epochepoch seconds plus textualreset_atwhen supplied by the helper, and redact tokens, local auth paths, and private account identifiers from scoped error messages. -
GET /workdirs?q=...&limit=N— authenticated read; searches only the default operator roots ($HOME/Projectand$HOME/.config) with bounded depth, count, and elapsed-time limits. Addgit_only=true&exclude_worktrees=truefor the curated project picker: only primary git working trees are returned, ordered by most-recent session cwd usage (last_used) and then name/path. -
POST /sessions(create),PATCH /sessions/{id}(title update),POST /sessions/{id}/send,POST /sessions/{id}/resume,POST /sessions/{id}/attachments?filename=...,DELETE /sessions/{id}— writes, require a bearer token. -
POST /sessionsnormally creates a fresh session fromagent, optionalcwd,title,id,prompt, andagent_args. Whenprovider_resume_idis present (alias:resume_id), the daemon imports an existing Codex or Claude provider conversation instead: it resolves the original cwd from local provider history, persists exactprovider_resumemetadata, and starts tmux with the canonical resume command. In resume-id mode, omitcwd,prompt, andagent_args; invalid, missing, ambiguous, or unsupported provider ids return structured errors. -
Attachment upload uses a raw binary request body (not multipart), capped at 25 MiB. The daemon writes the file under the session's private
attachments/directory with a sanitized filename and returns the remote path in the serve envelope. Empty or null titles clear the custom session title so clients can fall back to the session id. -
GET /sessions/{id}/attach— a WebSocket PTY attach: acapture-panesnapshot then a live byte stream from one daemon-ownedtmux pipe-panebroker per session (binary frames, renderable by xterm.js). Concurrent clients fan out from the same bounded in-memory stream; disconnecting one client leaves the others live, while a lagging client is disconnected so it cannot stall tmux or other clients. The broker uses a private ephemeral FIFO and retains no interactive-session terminal bytes after the final client disconnects. Snapshot capture drains live output into a bounded handoff buffer and performs one bounded fresh-snapshot recovery if that buffer overflows. After handoff, a supervised per-client pump keeps draining broker output independently of provider discovery, input, and resize work; a normal broker close drains already accepted frames under the WebSocket send bound, while lag/error teardown remains immediate. The client sends JSON control frames{ "text": "...", "key": "enter", "keys": ["c-c"], "resize": { "cols": 80, "rows": 24 } }. Token-gated; disconnect leaves the tmux session alive. Concurrent clients share the pane geometry; resize sequences are serialized and the last completed resize wins. A client may opt into authoritative Codex/Claude prompt events by sending{ "subscribe": ["provider-prompt.v1"] }. The daemon baselines the exact provider transcript at EOF, replies with anagent-session.attach.v1capabilitytext frame, and only after that acknowledgement emits laterprompt_submittedevents as bounded text frames; terminal snapshot/live output remains binary. The normative supported acknowledgement is:provideris"codex"or"claude"when supported andnullotherwise; an unsupported provider, unresolved exact transcript, unsafe transcript path, exhausted discovery budget, or session without already persisted exact provider identity returns the same object withsupported:false. Prompt subscription never uses cwd/time-window history heuristics to invent provider identity; sessions without exact identity remain on the consumer's fallback path. Clients that do not subscribe receive no event text frames. The normative event is:event_idis unique and opaque,submitted_atuses the provider timestamp when present (otherwise detection time), andtextis UTF-8 bounded toprompt_max_bytes;truncatedreports clipping. Events never contain transcript paths and are never logged or persisted by the daemon. Terminal and control queues are bounded: terminal frames receive bounded burst preference, while advisory prompt events may be dropped on saturation and must not delay terminal bytes. Consumers should retain their documented local fallback when capability is absent/false or an event does not arrive within its bounded fallback interval.
Every response uses the cli.agent-session.serve.v1 envelope and carries a machine identity (--machine /
AGENT_SESSION_MACHINE / --host / hostname) so an edge can aggregate several machines. Auth is a bearer token
(--token-stdin, --token, or AGENT_SESSION_TOKEN) on all write and attach endpoints, compared without an early-exit
on the token bytes; when no token is configured (or it is empty) those endpoints fail closed (503). Prefer
--token-stdin for launcher integrations so token material does not appear in process arguments. It reads one trimmed
token from stdin, rejects empty input, rejects multiple newline-separated tokens, and rejects input over 8192 bytes.
--token-stdin conflicts with --token; both forms avoid printing token material in errors. Use a strong,
high-entropy token.
Trust model: the daemon binds loopback and refuses a non-loopback bind unless --allow-non-loopback is passed, because
it drives a remote shell. Session reads (list / glance) are intentionally open on the bind address, while path-bearing
reads (workdirs), writes, and attach require the bearer token. Front the daemon with the agent-console edge (which
applies its own auth) and do not tailscale serve the raw serve port; expose only the edge, tailnet-only, no funnel.
Browser WebSocket clients cannot set an Authorization header, so the edge must proxy the attach and inject the bearer
server-side — never put the token in the ws:// URL/query.
Session survival across serve restarts: the daemon starts each session as a child tmux new-session -d, so the tmux
server shares the caller's cgroup. Under a systemd service that means it shares the unit cgroup, and stopping or restarting
the service can kill every live session. Set AGENT_SESSION_TMUX_SCOPE=1 to launch the tmux server inside a transient
systemd user scope (systemd-run --user --scope) so it lands in its own cgroup instead — a sibling of the service, so
sessions survive a daemon restart or even an explicit cgroup-wide kill. It is opt-in (the serve launcher sets it) and only
engages when a systemd --user manager is reachable; on any other host (no user manager, missing systemd-run, non-Linux)
it falls back to launching tmux directly. Pairs with KillMode=process on the serve unit for defense in depth.
Output contract
Human-readable text is the default. JSON is opt-in with --format json on command subcommands.
JSON output uses the workspace envelope: schema_version, ok, data, optional warnings, and error on failure.
Secret-safety boundary
Prompts are stored under the local agent-session state directory and are not printed in command output. For sensitive prompts, prefer
interactive start; one-shot run may need to pass the prompt through the underlying agent process command line depending on that agent's
CLI capabilities. send routes literal text through a private (0600) buffer file loaded into tmux, so it never appears in the tmux
command line or command output; the JSON contract reports only sent_text (a boolean) and the special-key names, never the text itself.
Values passed with --agent-arg are persisted in the private session record so durable resume can recreate the same provider invocation.
Do not put secrets in provider arguments. For Claude sessions, provider identity/resume flags such as --session-id, --resume/-r,
--continue/-c, --fork-session, and --from-pr are reserved for agent-session so the stored resume identity stays exact.
For secrets, prefer --text-stdin: --text <value> still places the literal in agent-session's own process arguments (visible in ps
to same-user processes), exactly as the existing --prompt flag does. send is not idempotent — keystrokes are delivered before the
command returns, so a retry after a mid-delivery failure can re-send; callers that auto-retry should account for this.