Session origin and state
What each session row says about the process it belongs to, how live, quiet and orphaned are decided, how to end a session, and what the reaper and a re-login do.
Added in v0.221.11 Every agent session is one row on the server. The row
names the process it belongs to, so orboto who can tell a session that is
alive from one that nobody works in any more.
One process, one row
The CLI in a session's shell uses the same session token as that session's wake channel, whatever its working directory:
- the token the
orboto claudesupervisor recorded for the folder, otherwise - the token the local MCP proxy pinned for its harness.
A call from a subfolder therefore never opens a second row with no scope, which
used to list the account mail of every project. messages, message-work,
agent-notify, claim and every other command carry the same token.
An HTTP MCP connection (claude.ai, Claude Desktop, Cursor) acts under a token built from the account, the credential and the client name. Each reconnect of one client resumes one row.
Two plain terminals in one checkout without an orboto launcher share one row.
Added in v0.230.0 Start each under its own launcher (orboto claude,
orboto kimi, orboto agy and the others) and each gets a row of its own, see
One session per account, checkout and scope.
A shell that needs its own session, for example a worktree agent with its own
scope and inbox, sets ORBOTO_AGENT_SESSION; every command honours it, it wins
over the adopted token and orboto never rewrites it.
What the row records
Every CLI, MCP and runner request names its origin:
| Field | Meaning |
|---|---|
host | First label of the host name, lower-cased. |
machine | A hash of the machine id, so a renamed host stays one machine. |
repo | The repository basename. A linked worktree names its main repository. Never a path. |
pid | The harness process the call runs under (the Claude Code or Codex ancestor of the CLI). |
start | That process's start time. |
client, version | orboto-cli or orboto-mcp. |
harness | claude-code, codex, mcp-oauth, mcp-stdio, lane, runner or cli. |
The credential kind (api_key, oauth, web, browser) comes from the
sign-in. A row without a host is unknown origin, except an MCP OAuth
connection or a lane, whose identity is the credential.
"Last seen" belongs to the row's own process. A contact from any other process
moves a separate "touched" time only, so a supervisor's socket, a forgotten
messages --follow or a bridge that outlived its client no longer keep a dead
session looking alive.
States
| State | Meaning |
|---|---|
live | Its own process was in contact within 90 seconds. |
orphaned | Only another process touched it within 90 seconds. |
quiet | Neither. A work lease or a lane keeps it listed. |
unknown_origin | The row names no process. This outranks the activity in state; activity always carries the activity alone. |
Reading it: orboto who
orboto who # live sessions
orboto who --orphaned # only orphaned rows
orboto who --unknown # only rows with an unknown origin
orboto who --state quiet
orboto who --for "asset pipeline"
orboto who --jsonThe table has the columns STATE, HOST, REPO, PID, HARNESS, CLIENT and AGE (since
the process started). who lists open rows that were reached within 90
seconds, rows a work lease holds and fresh lanes, never an ended row. Two live
sessions that answer to the same role address are marked (shared).
Visibility follows the permission. With admin:agents:read or
admin:system:read you see every session. Everyone else sees their own rows
and those declared on a project they are a member of; for another account's
row the details (machine, repository, pid, process start) are left out.
GET /v1/agent/who answers the same set; ?state= filters and
?include=inventory adds the rows of the Admin -> Agents -> Live tab. MCP
reads it with orboto_who and orboto_agent_presence.
Ending a session
orboto session end 3f9c1a2b --reason "terminal closed, work moved"session end takes the session id or an id prefix of eight or more hex
characters. You can end any session of your own account; ending any session
needs admin:agents:write. It also exists as orboto agent-session end,
POST /v1/agent/sessions/:id/end, and Admin -> Agents -> Live -> session menu
-> End session.
Ending a session:
- sends a
session_endednotice on every open stream of the row and closes it; the MCP channel turns it into one event and stops reconnecting, andmessages --followprints it and exits 0; - releases every active work lease of the row (status
cancelled, the time booked, resource claims freed, the ticket free for the next claim) and names the tickets asreleased work leases:; - books running timers and marks the row ended, which no passive contact
resumes. A later
session-startororboto clauderestart under the same token starts a fresh row.
The reaper
Every five minutes the instance ends open rows whose own process has been silent
longer than the setting agent_session_reap_minutes (default 60, from 5 to
10080). Change it under Admin -> Agents -> Live -> Session reaper or with
PATCH /admin/config. The reaper books timers, marks the row ended with the
reason reaper and writes an agents.session.reaped audit entry. A row that
holds a work lease is never reaped. A reaped row that declared a scope keeps its
token for the session retention window, so its process can resume it with a
heartbeat.
A re-login keeps the session
The session token of a CLI profile is built from the checkout, the profile name
and the instance URL, never from the credential. A Claude Code /login, a
refreshed OAuth token or a rotated key leaves the token, the row, its scope and
its mail where they were. Inside a supervised or channel session the token the
supervisor or proxy recorded wins, and ORBOTO_AGENT_SESSION wins over both.
All forms of orboto messages (plain, --json, --all) send the same
reference, so they read the same inbox.
Once, on the update that brought this, a CLI on a login profile computes a new
token. That shell gets a fresh row and declares its scope again with
session-start or agent-heartbeat. The old row ages out.
A session declares its scope
Added in v0.228.1 A session gets a role address and a place in orboto who only when it says what it is responsible for: a role plus one or more
projects or tickets.
orboto session-start --role worker --scope-projects ACME
orboto session-start --role review --scope-tickets ACME-12
orboto session-start --role coordinator # a control session, no projectorboto session-start without flags - the native CLI, the skill wrapper and
orboto_session_start on MCP alike - declares the checkout's project before
it registers: ORBOTO_PROJECT_KEY in the environment, then the env block of
.claude/settings.local.json, then the project that connects the origin
remote. ORBOTO_SESSION_ROLE=coordinator declares a control session instead.
A scope the session declared earlier stays. When nothing resolves, it prints
the command to run instead of the usual session line.
A heartbeat that leaves the session without a scope is refused:
orboto agent-heartbeatwithout--roleand--scope-projects(or--scope-tickets) prints the hint and exits 2.- A role without keys (
--role lead) or keys without a role is refused the same way.coordinatoralone is enough. --clear-scopeis refused too. To take a session out of presence, end it:orboto session end <id> --reason "...".
A session without a scope has no role address and does not appear in orboto who, orboto agents, Admin -> Agents -> Live or the MCP and chat presence
tools. Account mail and wakes still reach it. Lanes and runners need no scope:
their identity is the lane or the runner.
A script that calls POST /v1/agent/heartbeat directly, without the
x-orboto-origin header the orboto clients send, still gets its answer. Its
session is listed only once it declares a scope.
Clients older than 0.228.0 send no project with their start. Their heartbeats
without a scope are refused until you update them: the CLI prints
error: 422 [errors.agents.scope_required]: This session declares no scope ..., and the local MCP server logs channel heartbeat failed: orboto API 422.
Added in v0.230.0 A tool server that starts beside a session whose scope the
CLI already declared is not refused. A stdio MCP server can run under another
session token than the CLI of its harness (a hand-configured @orboto/mcp
entry, or a harness whose CLI runs under a profile namespace while the server
does not). Its heartbeat then carries no scope. When the same process - same
machine, same process id and, where both report one, the same start time -
already holds a live session of your account with a declared scope under another
token, the instance answers with that session instead of refusing, and the
server continues under that token. A server with a wake channel follows the
inbox stream of that session and announces its channel there, so the session you
declared is the one mail wakes; it also receives the whole unread mail of that
session, also mail older than what the server had seen under its own token.
Nothing counts as a refused heartbeat. A heartbeat from another process, from
another machine, from a reused process id, or one that starts a session without
a scope, is still refused.
One session per account, checkout and scope
Added in v0.228.1 A checkout is one working tree: the main clone or a
linked worktree, each on its own. Added in v0.230.0 Several live sessions of
one account run in the same checkout as long as each one is responsible for
something else. A session on ACME and a second session on BETA in the same
folder both start without a question, and each keeps its own role addresses,
mail, ticket leases and status line.
A start is refused only when another live session of the account runs in the
same checkout on the same machine in another process, whatever its harness, and
the two scopes overlap. That holds for orboto session-start and for the scope
declaration of an orboto claude, orboto kimi or orboto agy start. Two
scopes overlap when they share
- a project key (
ACMEandACME), - a ticket key (
ACME-7andACME-7), - a ticket inside the other session's project (
ACME-7againstACME), or - the same control role, where neither names keys (two
coordinatorsessions).
A start that declares no scope of its own would inherit one, so it counts as overlapping with every live session there and is refused too (unless the same process declared a scope before: it claims that one). The refusal names the session it ran into: its id, harness, how long it has been running, the ticket it holds, where it runs, the keys both sessions claim and every other overlapping session by id. You then have three ways on:
| Way on | The overlapping sessions | This session |
|---|---|---|
A scope without the named keys, for example --scope-projects BETA | Keep running unchanged. | Runs beside them with its own scope. |
--adopt | Every one of them: unread mail, ticket leases and timer move to this session in one step, then each ends. | Takes over the scope if you declared none. |
--end-previous | Every one of them ends. Their leases are released, their time is booked. | Takes over the scope if you declared none. |
--adopt and --end-previous take over all overlapping sessions, not only the
one the message names first, and session-start prints one line for each. A
start whose scope is disjoint leaves the other sessions alone even when it
passes one of the flags: nothing is taken over.
A refused orboto claude start still opens Claude Code. It prints the refusal
and the way out, and the session runs without a declared scope until you run one
of the commands inside it:
orboto claude: the session scope was not declared - Another live session of this account runs in widgets on build-host with an overlapping scope (ACME): 3f9a1c2e (claude-code, 42 min, ACME-7). Start with a scope that shares none of these keys to run both side by side, or again with --adopt to take over the scope, mail and leases of every overlapping session (3f9a1c2e), or with --end-previous to end them.
declare a scope without ACME (`orboto session-start --role <role> --scope-projects <KEYS>`) to run both side by side, or run `orboto session-start --role worker --scope-projects ACME --adopt` or `orboto session-start --role worker --scope-projects ACME --end-previous` inside the session to replace itWith several overlapping sessions the second line ends with "to replace it and
2 more: 4d2e7f10, 9b0c3a55 - all of them", naming the further sessions. On MCP,
orboto_session_start { scope: { role, projectKeys } } with keys the other
session does not hold runs beside it, and { previous: "adopt" } or
{ previous: "end" } replaces every overlapping session.
A restart, where the previous process has exited, ends the old session on its
own: the client checks the other process on this machine and, when it is gone,
prints previous session <id> (pid N) has exited, ended it and carries on. A
session left behind by a crashed orboto claude keeps beating under the dead
launcher's process, so the next launcher in that checkout ends it the same way.
A process of another user on the machine always counts as running. The
same process under a new session token (after a new login), another machine,
another checkout of the same repository, lanes, runners and sessions started
with orboto agent start are never a second start. Two containers of one
image or two cloned virtual machines count as different machines when their
host names differ.
Each session keeps its own token and row
Added in v0.230.0 The CLI derives a session's token from the host and the
checkout (plus the profile), so two launchers in one checkout used to resolve
the same token and share one row: the second declaration overwrote the first
scope. A launcher (orboto claude, orboto kimi, orboto agy and the other
launchers) now checks the tokens the other launchers on the machine run under.
While the checkout's token is taken, it appends the lowest free suffix, -s2
for the second session, -s3 for the third. The ref that orboto session-start
prints carries it. The processes of a session, its status line, session-start,
the inbox, messages and its ticket leases all resolve to that session's own
row. A token set explicitly in ORBOTO_AGENT_SESSION is never changed.
The token of a session that just ended stays taken for the resume grace (10 minutes by default), so a new session started in that time does not land on the ended row. Only a start that continues the same conversation of the same harness takes the token back and resumes the row:
orboto claude --continue, or--resume <session id>, naming the conversation that session ran.orboto kimiandorboto agywith--resume <id>or--continue, naming the session of their own harness. orboto records that harness session id once the session has declared its scope: at the start, or later withorboto session-startin its shell.--continuetakes the one the newest session of the same harness in this checkout recorded; a conversation of one harness never continues the row of another.orboto codexandorboto geminireport no session of their own to orboto, so a restart of theirs inside the grace starts a fresh row.
A fresh start, a bare --resume picker and another conversation stay off the
token. The instance holds the same line: only the same process, or a new process
continuing the same conversation in the same checkout on the same machine,
resumes an ended row; any other start gets a fresh row. The ended row keeps its
leases until they run out. Mail addressed to it does not move to the new
session: when the instance removes the row after the grace, the mail is closed
unread as addressed to a session that is gone.
Added in v0.230.0 A process that runs under a session's token (its
launcher's, or one set in ORBOTO_AGENT_SESSION) keeps its local state per
session, also without a login profile: the current ticket its status line shows
and its work-session pointer. The status line shows the ticket of the session it
is drawn for, never a marker another process left on the machine; a process that
belongs to no session, in a folder without its own .orboto.env, shows no
ticket.
Refused heartbeats
Added in v0.228.1 Every refused heartbeat counts for its account over 24
hours. A normal start never counts, and Added in v0.230.0 neither does a
start whose scope shares nothing with the live sessions of its checkout. A
second start counts only when the scopes overlap. orboto who lists them under
the table, per account, with the number of missing scopes and second starts and
where the last one came from:
Refused heartbeats in the last 24 hours (no role address, not listed):
agent@acme.example: 3 (no scope 2, second start 1), last 10:02 from build-host/widgets (claude-code)Admin -> Agents -> Live shows the same list in the Refused heartbeats panel.
Administrators see every account, everyone else their own. Each refusal is in
the audit log as agents.heartbeat.refused, at most once every 10 minutes per
account and reason, and the metric orboto_agent_heartbeat_refusals_total
counts them by reason.
Write attribution
Added in v0.228.1 Every write records who made it and from which agent session. orboto reads the session from the instance token the call carries (x-orboto-agent-session, which the CLI, the skill wrapper and the MCP server send on every call), never from a field in the request body.
- Comments, ticket history and audit-log entries store the agent session next to the user. Admin → Audit Log shows "from agent session" with the session id under the actor;
GET /admin/audit-log?agentSessionId=<id>lists everything one session wrote. Audit forwarding carries it asactor.sessionId(webhook) andcs3(CEF). - Timers and work sessions belong to the session that runs them. A call that names another session (an
agentSessionTokenthat is not its own token, or another session'sagentSessionId) is refused with 403errors.agents.session_not_caller. SendingagentSessionIdwithout the instance header is deprecated. - A session belongs to the process that runs it. If another process uses the token, either while the owner is still running or as a different agent harness (for example a Codex session that inherited the token of a Claude Code session in the same checkout), its timers and work sessions are refused with 409
errors.agents.session_foreign_process, which names the session, harness and pid, and its other writes carry no session. Its heartbeat and its wake stream are refused the same way while the owner is a running process of another harness, so the two processes cannot take the session from each other. Give the second process an instance token of its own (ORBOTO_AGENT_SESSION=<own value>in it) or its own identity (orboto profile bind <harness> <profile>). After a restart of the same harness,orboto session-startregisters the session again. - A process of another harness that takes over a quiet session does not inherit its running timer: the timer is booked to the previous process first.
orboto timerandorboto timer stopnever fall back to the running timer of another process's session: the read reports no timer, the stop answers 409errors.time.timer_of_other_session.- Timer answers name who runs the timer and its session (
userEmail,agentSessionLabel);orboto work-sessionsprintssession <name>#<id>for each lease. orboto whoamiprints the session the call acts under,session none - ...before the firstsession-start, or a warning naming the owning process and the cure.whoami --jsonand the MCP toolorboto_whoamireturn it asagentSession.
The process check relies on what each client reports about itself. It prevents accidental misattribution between agents that share one account; to separate agents reliably, give each harness its own profile.
See also
Sessions, scope and the wake ledger
Run several agent sessions on one account without them waking each other - declare what a session is responsible for, address one session directly, notify every connected session at once, and measure whether the wakes were right.
Responsibility map
Say which agent or person owns which topic, look the owner up before you send a request, and let agent-notify refuse a request that went to the wrong address.