orbotodocs
Agents & AI

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 claude supervisor 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:

FieldMeaning
hostFirst label of the host name, lower-cased.
machineA hash of the machine id, so a renamed host stays one machine.
repoThe repository basename. A linked worktree names its main repository. Never a path.
pidThe harness process the call runs under (the Claude Code or Codex ancestor of the CLI).
startThat process's start time.
client, versionorboto-cli or orboto-mcp.
harnessclaude-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

StateMeaning
liveIts own process was in contact within 90 seconds.
orphanedOnly another process touched it within 90 seconds.
quietNeither. A work lease or a lane keeps it listed.
unknown_originThe 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 --json

The 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_ended notice on every open stream of the row and closes it; the MCP channel turns it into one event and stops reconnecting, and messages --follow prints 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 as released work leases:;
  • books running timers and marks the row ended, which no passive contact resumes. A later session-start or orboto claude restart 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 project

orboto 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-heartbeat without --role and --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. coordinator alone is enough.
  • --clear-scope is 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 (ACME and ACME),
  • a ticket key (ACME-7 and ACME-7),
  • a ticket inside the other session's project (ACME-7 against ACME), or
  • the same control role, where neither names keys (two coordinator sessions).

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 onThe overlapping sessionsThis session
A scope without the named keys, for example --scope-projects BETAKeep running unchanged.Runs beside them with its own scope.
--adoptEvery 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-previousEvery 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 it

With 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 kimi and orboto agy with --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 with orboto session-start in its shell. --continue takes 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 codex and orboto gemini report 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 as actor.sessionId (webhook) and cs3 (CEF).
  • Timers and work sessions belong to the session that runs them. A call that names another session (an agentSessionToken that is not its own token, or another session's agentSessionId) is refused with 403 errors.agents.session_not_caller. Sending agentSessionId without 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-start registers 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 timer and orboto timer stop never fall back to the running timer of another process's session: the read reports no timer, the stop answers 409 errors.time.timer_of_other_session.
  • Timer answers name who runs the timer and its session (userEmail, agentSessionLabel); orboto work-sessions prints session <name>#<id> for each lease.
  • orboto whoami prints the session the call acts under, session none - ... before the first session-start, or a warning naming the owning process and the cure. whoami --json and the MCP tool orboto_whoami return it as agentSession.

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

On this page