Closedloop.ai
Mechanisms

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 classify a recognized value identically, so for the vocabulary the menu offers the web cloud path and the desktop local path agree. (The residual per-path differences — unrecognized spellings and rollout-gated predicates — are called out below.) 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, Inactive, Failed (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 — 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. Status sits between the two: its options are a fixed vocabulary rather than data-derived, and most of them filter through a purpose-built predicate rather than a plain equality. Since ISS-5592 no spelling is folded on arrival from a bookmark or a saved view — the retired completed/abandoned included — so an unrecognized status falls through to a plain equality — and the two paths compare differently. The cloud query matches only rows that literally store that string, while the local path lowercases both sides first, so a request for FutureState can match a locally stored futurestate that the cloud path would miss. That case fold is now the whole of the local canonicalization: ISS-5592 also removed the arm that mapped a locally stored running onto active, having established that no desktop write path has ever produced the value. What such a row stores is still not the same on both surfaces — the local store can hold a locally-canonicalized spelling cloud ingest's fold would never write — but ISS-10364 closed the DISPLAY half of that divergence: an unrecognized status now reads Active on both surfaces, the same fail-open every other unmodelled spelling takes, rather than unknown on one and active on the other. Both populations remain unreachable in practice; neither surface has a producer left that writes either shape. 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) — the window keeps the sessions whose lastActivityAt falls inside it, so a session that started before the window is still included when its last activity landed there, while one that was active during the window and then active again after it ends is excluded, because only that final lastActivityAt is tested. The export deliberately windows on the same field so it paints the same cohort as the table. Every session carries a lastActivityAt — the column is NOT NULL, floored at the session's start time — so no row escapes the activity window for want of a timestamp. Only the analytics aggregation bounds the session start time (sessionStartedAt), on the principle that a session belongs to the period it started in.

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 row with no usable 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

The Status facet, the Status-column badge, and the Status-column sort all speak the same three-value vocabulary — the persisted lifecycle values, and nothing else:

StatusLabelMeaning
activeActiveThe run is in progress.
inactiveInactiveThe run is over and did not fail.
errorFailedThe run failed.

waiting, stale, and unknown are retired. ISS-10364 (operator ruling, Chris Chenault, 2026-09-23/2026-09-24 — "Unparsable status are already converted to Active on import. Don't distinguish between active and stale," then "let's drop waiting as well") removed all three read-time derivations a render used to layer on top of the three stored values: a run awaiting user input no longer displays as Waiting, a long-silent active row no longer displays as Stale, and an unparseable stored status no longer displays as Unknown. Every one of those shapes now displays its stored lifecycle value — Active — with no derived word distinguishing it, on both the cloud and Desktop paths.

The underlying facts are not gone, only the words. session_detail.awaitingInputSince is still written and read; it simply no longer projects into a status word. STALE_SESSION_DISPLAY_THRESHOLD_HOURS survives only as an input to the desktop collector's own wedged-rebuild classifier — collector logic, not a render. waiting is still accepted as an incoming status from a version-skewed client predating ISS-5981, and ingest folds it to active exactly as it folds any other unrecognized spelling, preserving the awaitingInputSince anchor so the timestamp survives even though the word does not; stale and unknown were never writable at all, only read-time derivations. A row written before ISS-5981 closed cloud ingest's tolerance can still carry a literal stored waiting — it now displays Active like any other non-terminal row.

completed and abandoned were retired. (ISS-4654/ISS-5592, earlier than waiting/stale/unknown above, and retired the same way): ordinary unrecognized values that fail open to active on write, the same fail-open every other unmodelled spelling takes. An API or MCP consumer may still send any non-empty status string — ingest accepts it rather than rejecting the write, but does not persist it verbatim: it folds every other unmodelled or retired spelling onto one of the three lifecycle values before the write, so a new row's stored value is always one of those three, and a read never returns anything else as a display word either.

inactive and error are the terminal statuses: the run is over, so it can never be genuinely "awaiting input". active is the only non-terminal value, and a row's Duration cell grows against the clock whenever it displays Active — there is no longer an indeterminate case measured separately from it. The session-detail page projects a terminal row onto its own outcome vocabulary, where error reads Failed and inactive reads Completed — on the cloud path that derivation is a fallback: a stored legacy state that still parses takes precedence for any row, so a version-skewed row can show something else entirely, while the desktop-local read derives the detail state from the raw status and timestamps alone. So a finished run typically reads Inactive in the list and Completed on its detail page — one outcome under two words, which is a known inconsistency between the two surfaces rather than a distinction to read anything into.

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 methods — git 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 aim to classify, filter, and bucket a session identically — the cost buckets and the leaf status folds live in shared modules, but the status projection and the facet predicates are hand-kept mirrors (one per path), and the cloud and local mappers derive some fields independently. Keep the two in sync when you touch either; the known residual differences are the ones the Filters and status-model sections above call out.

On this page