Closedloop.ai

Sessions

The Sessions surface — the list, detail, and trace of every AI coding run Closedloop.ai observes, plus the filters, status model, cost buckets, and PR/branch attribution behind it.

A session is a single AI coding run — one Claude, Codex, Cursor, Copilot, or OpenCode invocation — that the desktop client observed, normalized, and (when signed in) synced to the cloud. The Sessions surface is where you read them: a filterable list of every run, a detail page per run with a turn-by-turn trace, and the summary KPIs that roll up across the current filter. It renders from the same shared package on both the web app and the desktop client, so the two surfaces show the same rows.

This page describes the Sessions surface itself. The aggregated-data layer covers the Insights dashboards that roll these sessions up; the agent-components inventory covers the sub-agents, skills, and commands a session used; and the agent-sessions MCP tools expose the same data programmatically.

The list

The Sessions list is one row per run. Each row carries the owner, harness, model, repository, working branch, duration, estimated cost, and a status badge, plus links to any PR or branch the run touched. The row's token counts feed the cost derivation but are not surfaced as a per-run column, so the list is not a token-usage comparison. Rows are sortable by user, status, repository, harness, model, duration, cost, start time, or last activity.

The list is windowed by design. On the desktop client, row-loading list reads default to a rolling 90-day window (all-time is an explicit toggle), while the summary aggregates stay all-time because they are cheap to compute in SQL — see aggregated data for why the list and the aggregates can be scoped differently. On the web app the list reads the authenticated /agent-sessions routes against synced cloud metadata.

Substantive vs. idle

An idle session is a run with zero turns, zero tokens, and zero tool uses — typically a harness that opened and closed without doing billable work. The server can narrow reads to substantive (excludes idle rows), idle (only idle rows), or all (both) via a quality param, and that narrowing applies identically to the list, the usage/analytics aggregations, and export so the counts stay consistent.

The current web and desktop-renderer clients do not send quality (the earlier Substantive | Idle | All segment control was reverted in FEA-4194 as an unapproved taxonomy), so the effective default everywhere is all — idle rows are shown. The quality contract is kept server-side because it is version-skewed: an older desktop build that still sends an explicit quality narrows only its own read.

Filters

The filter menu is a single source of truth shared across every surface that filters sessions — the shared menu, the cloud query builder, and the desktop local matcher all classify a session identically, so the web cloud path and the desktop local path never disagree. The facets are:

FacetWhat it matches
OwnerThe user attributed as the run's owner. Options are data-derived from the usage byUser breakdown; the selection filters on the canonical userIds.
StatusActive, Waiting, Completed, Failed, Abandoned (see the status model below).
HarnessThe agent that produced the run (Claude, Codex, Cursor, Copilot, OpenCode). Options are derived from the sessions actually present.
ModelThe model the run used. Options are data-derived from the usage summary.
RepositoryThe repository the run worked in.
CostCoarse cost-range buckets (see cost buckets).
Autonomy tierHow autonomously the run operated.
Change presenceWhether the run produced changes (has_changes / no_changes), defined against the same files-changed / lines-added / lines-removed columns the detail row renders.
PR associationWhether the run is linked to a pull request (has_pr / no_pr).

Multi-select facets OR within a facet and AND across facets, so selecting two cost buckets widens the cost slice while a repository selection narrows it. Unknown values are not uniformly harmless. The fixed-vocabulary facets — autonomy tier, cost, change presence, and PR association — match against a known id set, so an unrecognized id is simply ignored. But data-derived facets — Status, Harness, Model, Repository (and Owner) — feed their values straight into equality filters on both the cloud and local paths, so a stale value that no longer matches any session just empties the list rather than being ignored. The quality segment is the one facet that rejects an unsupported value outright, so the segment and the query builder can never disagree.

Date filters carry a specific meaning per read. On the cloud path the list, the usage summary, and the CSV export all treat startDate/endDate as an activity window on the run's last-activity time (lastActivityAt) — a session that started earlier but was active within the window is still included, and the export deliberately windows on the same field so it paints the same cohort as the table. Only the analytics aggregation bounds the session start time (sessionStartedAt), on the principle that a session belongs to the period it started in. Rows with a null timestamp are excluded from a date window rather than silently counted.

Cost buckets

The Cost facet buckets sessions into four coarse USD ranges, compared on the displayed (2-decimal) cost value so the filter and the Cost cell always agree:

BucketRange
≤ $1[0, $1] (inclusive of $1.00)
$1 to $10($1, $10]
$10 to $50($10, $50]
$50+> $50

The buckets partition the displayed cost grid with no gap and no overlap: each upper bound is inclusive and each lower bound (except the first) is exclusive, so a row the Cost cell renders as exactly $1.00 falls in ≤ $1, not $1 to $10. Selecting several buckets ORs them into a broader "high-cost sessions" slice.

Buckets are numeric predicates only — they never match an unknown cost. A session's cost is known when it was priced from captured usage (estimatedCost > 0) or is billed through a subscription; those render a $ figure. A session with no priced cost and no subscription is unknown — whether it burned tokens with no pricing data for its model, or did no billable work at all — and renders the honest sentinel instead of a misleading $0.00. Unknown-cost sessions are excluded from every numeric cost bucket, exactly as a null timestamp is excluded from a date window; they are never coerced to a placeholder $0 that would wrongly satisfy the "≤ $1" bucket.

The status model

A session's status is one of five canonical values, rendered with the labels below:

StatusLabelMeaning
activeActiveThe run is in progress.
waitingWaitingThe run is paused awaiting user input.
completedCompletedThe run finished.
errorFailedThe run failed.
abandonedAbandonedThe run was left idle and never resumed.

Active and Waiting share one persisted value. A run that is awaiting user input is stored as active but displays as Waiting whenever it has an awaiting-input marker and has not yet ended. The displayed status is projected once at the read boundary so the badge, the Status-column sort, and the Status facet filter all key off the same value — the cell, the sort, and the filter can never disagree.

completed, error, and abandoned are terminal: the run is over, so it can never be genuinely "awaiting input". Failed and Abandoned are preserved as distinct terminal outcomes rather than collapsing into a generic Blocked state, so a run's detail page shows the same outcome the list does. One deliberate exception: an Abandoned run that nonetheless shipped a pull request — one it raised or merged (swept as idle only after the PR) — is a successful terminal outcome and classifies as Completed. The rescue fires when the run carries any linked PR (a non-empty PR list or a merged PR), so a raised-but-not-yet-merged PR is enough; the PR-outcome signal is threaded into the classifier so web and desktop agree. A Failed run is never rescued by a PR.

Summary KPIs

Above the list, the summary cards roll up token totals, estimated cost, and session counts across the current filter. The cost figure splits estimated spend into subscription-covered usage versus API-key (metered) compute: headline cost is the metered spend, because subscription usage is a hypothetical "would-have-cost" rather than a real charge. Sessions whose billing source isn't known yet fold into the API-key figure rather than being assumed subscription-covered.

Every KPI is aggregate-backed — a headline total is never a sum of one page of a paginated list, which is why the summary count and the count you get by paging the list can be computed differently. On the cloud path (and the desktop SQL summary path) the total is a true SQL aggregate or a known-complete collection. On the desktop client there is one bounded exception: when a Repository or a local-only facet filter forces the hydrated fallback, the working set is capped at 5,000 sessions (MAX_WORKING_SET_SESSIONS), so on a corpus larger than that the summary cards for those filters can trail the true list total rather than reconciling exactly. A KPI that needs data the current context doesn't have (for example a GitHub-gated metric on an org with GitHub disconnected) reports itself as unavailable rather than rendering a misleading zero.

The detail page and trace

Opening a session row navigates to its detail page: the run's metadata, its linked artifacts (PRs, branches, commits, and Closedloop documents), an activity breakdown, and the trace — a turn-by-turn timeline of the run reconstructed from its transcript. The trace renders each user turn, assistant turn, tool call, and any sub-agent the run spawned as a nested sub-trace, with the sub-agent's own identity. Token usage is reported at the parent-run level, not per sub-agent — there is no truthful per-sub-agent token source today, so the trace does not attribute tokens to an individual sub-agent. Trace comments can be attached to a text selection in the trace and read back over the MCP tools.

The transcript itself — the raw JSONL trajectory the trace is built from — always stays on the local machine. A copy leaves the device only when full transcript sync is enabled: the desktop client uploads a session's contents only under the full sync-observability consent tier (PRD-532 §7), and only after applying secret-pattern redaction to each JSONL line before it is staged to S3. So the raw source is never copied to cloud; what is archived and readable here — and, for available files, over the transcript MCP tool — is that pattern-redacted JSONL copy, present only for sessions synced while the full tier was in effect.

Session → PR and branch attribution

A session's attribution — the branches, PRs, and commits it gets credit for creating — is not taken from the harness-reported git branch. That field records the branch of the session's working-directory checkout, which is wrong for every worktree flow (the session sits in the main checkout while all real work happens through a worktree) and goes stale after any mid-session checkout. The extractor still mints a workspace branch reference directly from session.gitBranch (the start branch is workspace context, not a created branch), but a workspace reference never attributes; attribution derives only from the structured created references the extractor mints at parse time from the run's actual tool use.

The extractor scans each run's shell tool uses across every supported harness and splits branch, PR, and commit references by evidence:

  • Write methodsgit push, gh pr create, git commit — mint a created relation. These are what attribute a branch, PR, or commit to the run.
  • Read methods — the session's start branch, git checkout, git worktree add — stay a workspace relation and never attribute.
  • A failed push or gh pr create mints no reference at all, so a failed attempt never mis-attributes.

A created PR's head branch resolves evidence-first — from gh's own create output or an explicit --head flag, else the run's nearest preceding branch write — and default branches (main, master, develop, HEAD) are always rejected, so a PR opened from a worktree on feat/x is never mis-attributed to main.

Each reference is identified by the concrete thing it points at, not by the shell command's text or a content hash. A commit reference is minted only for the SHA inside a real [<branch> <sha>] <subject> git-commit summary line — never for an arbitrary hex token echoed in output — a PR is identified by its repository full name plus PR number (so the same PR number in two different repositories never collides), and a branch by its name. Because each reference resolves to a specific SHA / repo+number / branch name rather than the surrounding output, the same run parsed on three machines resolves to the same set of references. (This is distinct from the component-content identity in agent components, where FEA-4335 keys a component to the hash of its definition bytes.)

Sub-agent work is attributed to the parent run. When a session spawns a Task sub-agent — or a nested workflow sub-agent — that sub-agent's gh pr create / git commit / git push is folded into the parent session's landed code, deduplicated by tool-use id so nothing is double-counted. This is why an orchestrator or night-crew run gets credit for the PRs and branches its sub-agents actually opened.

Both surfaces

Everything above renders on both the web app and the desktop client through one shared package and one wire contract. Source selection on the desktop client is auth-driven: signed in and online, reads go to the cloud routes (byte-identical to web); signed out or offline, the client falls back to its own local SQLite session store (personal scope only). The two paths classify, filter, and bucket a session identically because the filter predicates, the cost buckets, and the status projection all live in one shared module — but the cloud and local mappers derive some fields independently, so keep the two in sync when you touch either.

On this page