diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f00d80..c067806 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,41 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
+### Added
+
+- **Homepage status frame.** The `/home` page now opens with a 5-card
+ status row above the install-hero / offboard-strip: **Last sync**
+ (your last `agnes pull`), **Sessions**, **Prompts**, **Tokens used**,
+ **Projects worked on**. A pill toggle switches the window between
+ 24h (default) and 7d. Backed by `GET /api/me/home-stats?window=` which
+ joins `users`, `usage_session_summary`, and `usage_events` in a
+ single DuckDB round-trip; the initial paint is SSR'd from the same
+ helper (`app.api.me.compute_home_stats`) so there's no spinner.
+ Visibility is gated on (a) the operator flag
+ `instance.home.show_status_frame` (yaml) /
+ `AGNES_HOME_SHOW_STATUS_FRAME` (env), default `true`, AND (b) the
+ caller being `onboarded`. Cautious-rollout instances can hide the
+ frame entirely; on every install, first-day users still see a clean
+ install-hero before zero-value stats show up.
+- **Per-user pull tracking.** `GET /api/sync/manifest` now stamps
+ `users.last_pull_at` as a side effect. `agnes pull` (and the
+ Claude Code `SessionStart` hook that wraps it) imprints the
+ analyst's "last sync" timestamp for the new homepage card.
+- **Token counters on `usage_session_summary`.** Four new BIGINT
+ columns (`input_tokens`, `output_tokens`, `cache_read_tokens`,
+ `cache_creation_tokens`) summed from JSONL `message.usage.*` per
+ assistant turn. `USAGE_PROCESSOR_VERSION` bumps 1 → 2, which the
+ session-pipeline reprocess loop uses to invalidate stale summaries
+ and backfill tokens on the next tick.
+
### Changed
+
+- Schema migration **v43 → v44** (`_v43_to_v44`): idempotent `ALTER
+ TABLE … ADD COLUMN IF NOT EXISTS` for `users.last_pull_at` plus the
+ four token columns above. Fresh installs receive them inline from
+ `_SYSTEM_SCHEMA`; upgrade path runs the function. All new columns
+ default to NULL / 0 so existing rows backfill cleanly without a
+ separate migration step.
- **Marketplace cover photos served with aggressive browser caching.**
`/api/marketplace/curated/.../asset/...`, `/api/marketplace/curated/.../mirrored/...`,
and `/api/store/entities/{id}/photo` now respond with
diff --git a/app/api/me.py b/app/api/me.py
index 913890f..7f5c41d 100644
--- a/app/api/me.py
+++ b/app/api/me.py
@@ -57,3 +57,134 @@ async def post_onboarded(
result="ok",
)
return {"status": "ok", "onboarded": target}
+
+
+# ---------------------------------------------------------------------------
+# GET /api/me/home-stats — backing data for the /home status frame
+# ---------------------------------------------------------------------------
+
+
+_WINDOW_INTERVALS = {
+ "24h": "INTERVAL 24 HOUR",
+ "7d": "INTERVAL 7 DAY",
+}
+
+
+def _username_for_stats(user: dict) -> str:
+ """Map a users row to the filesystem username used by the session
+ collector and stored in ``usage_session_summary.username``.
+
+ Mirrors ``app.api.admin_user_sessions._username_from_user``: the
+ session collector writes JSONL under the OS username of the agent
+ process which, for current deployments, equals the email local-part.
+ Kept inline here so this endpoint has no cross-module dependency on
+ an admin-only helper; if the mapping evolves both copies must update.
+ """
+ email: str = user.get("email", "") or ""
+ return email.split("@")[0] if "@" in email else email
+
+
+def compute_home_stats(
+ conn: duckdb.DuckDBPyConnection, user: dict, window: str = "24h"
+) -> dict:
+ """Pure helper that returns the home-stats payload for the given user.
+
+ Shared by the HTTP endpoint and the /home Jinja handler (server-side
+ initial render). Unknown windows clamp to ``24h`` so callers never
+ need to pre-validate. Returns a dict with ISO-stringified
+ ``last_pull_at`` (or None) so the same shape works for both JSON
+ serialization and Jinja rendering.
+ """
+ interval = _WINDOW_INTERVALS.get(window)
+ if interval is None:
+ window = "24h"
+ interval = _WINDOW_INTERVALS["24h"]
+
+ username = _username_for_stats(user)
+
+ # f-string interpolates only the validated interval literal above;
+ # all user-controlled input flows through bound parameters.
+ sql = f"""
+ WITH win AS (
+ SELECT current_timestamp - {interval} AS since
+ ),
+ sess AS (
+ SELECT
+ COUNT(*) AS sessions,
+ COALESCE(SUM(user_messages), 0) AS prompts,
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read,
+ COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation
+ FROM usage_session_summary, win
+ WHERE username = ? AND started_at >= win.since
+ ),
+ proj AS (
+ SELECT COUNT(DISTINCT cwd) AS projects
+ FROM usage_events, win
+ WHERE username = ?
+ AND cwd IS NOT NULL
+ AND occurred_at >= win.since
+ ),
+ u AS (
+ SELECT last_pull_at FROM users WHERE id = ?
+ )
+ SELECT
+ u.last_pull_at,
+ sess.sessions, sess.prompts,
+ sess.input_tokens, sess.output_tokens,
+ sess.cache_read, sess.cache_creation,
+ proj.projects
+ FROM u, sess, proj
+ """
+ row = conn.execute(sql, [username, username, user["id"]]).fetchone()
+
+ if row is None:
+ return {
+ "window": window,
+ "last_pull_at": None,
+ "sessions": 0,
+ "prompts": 0,
+ "tokens": {
+ "input": 0, "output": 0,
+ "cache_read": 0, "cache_creation": 0,
+ "total": 0,
+ },
+ "projects": 0,
+ }
+
+ (last_pull_at, sessions, prompts,
+ input_t, output_t, cache_read, cache_creation, projects) = row
+ return {
+ "window": window,
+ "last_pull_at": last_pull_at.isoformat() if last_pull_at else None,
+ "sessions": int(sessions or 0),
+ "prompts": int(prompts or 0),
+ "tokens": {
+ "input": int(input_t or 0),
+ "output": int(output_t or 0),
+ "cache_read": int(cache_read or 0),
+ "cache_creation": int(cache_creation or 0),
+ "total": int((input_t or 0) + (output_t or 0)
+ + (cache_read or 0) + (cache_creation or 0)),
+ },
+ "projects": int(projects or 0),
+ }
+
+
+@router.get("/home-stats")
+async def get_home_stats(
+ window: str = "24h",
+ user: dict = Depends(get_current_user),
+ conn: duckdb.DuckDBPyConnection = Depends(_get_db),
+):
+ """Return the five counters rendered in the /home status frame for
+ the calling user, over a 24-hour or 7-day window.
+
+ Single round-trip: one DuckDB query joins ``users``,
+ ``usage_session_summary``, and ``usage_events`` so the homepage
+ renders without N+1. Missing rows (new user, no telemetry yet)
+ surface as zeros / null rather than 404 — the frame still renders
+ cleanly for first-day analysts.
+ """
+ return compute_home_stats(conn, user, window)
diff --git a/app/api/sync.py b/app/api/sync.py
index cb9c486..1b1cf94 100644
--- a/app/api/sync.py
+++ b/app/api/sync.py
@@ -798,7 +798,25 @@ async def sync_manifest(
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
- """Return hash-based manifest of all synced data, filtered per user."""
+ """Return hash-based manifest of all synced data, filtered per user.
+
+ Side-effect: stamps ``users.last_pull_at`` so the /home status frame
+ can show when the analyst last pulled. This GET is the canonical
+ "I am about to sync" signal — agnes pull hits it first, then
+ downloads parquets whose hash changed. UI bumps (manifest browsed in
+ a browser session) also count; cheap and accurate enough for a
+ homepage card.
+ """
+ try:
+ conn.execute(
+ "UPDATE users SET last_pull_at = current_timestamp WHERE id = ?",
+ [user["id"]],
+ )
+ except Exception:
+ # Never block a pull because the stamp UPDATE hit a transient
+ # issue (locked WAL, partial migration window). The manifest
+ # itself is the load-bearing payload.
+ pass
return _build_manifest_for_user(conn, user)
diff --git a/app/instance_config.py b/app/instance_config.py
index 207523e..14d27da 100644
--- a/app/instance_config.py
+++ b/app/instance_config.py
@@ -250,6 +250,32 @@ def get_home_automode_visibility() -> bool:
return str(raw).strip().lower() not in ("0", "false", "no", "off", "")
+def get_home_status_frame_visibility() -> bool:
+ """Whether /home renders the homepage status frame (Last sync,
+ Sessions, Prompts, Tokens, Projects).
+
+ The template ALSO gates rendering on ``users.onboarded`` so a
+ fresh user sees a clean install-hero before the all-zero stat
+ cards. This helper is the operator-level master switch; the
+ onboarding gate is a UX coherence rule layered on top.
+
+ Cautious-rollout instances that would rather not expose token
+ counters to analysts yet can disable with
+ ``AGNES_HOME_SHOW_STATUS_FRAME=0`` (or
+ ``instance.home.show_status_frame: false`` in YAML).
+
+ Resolution: env var > ``instance.home.show_status_frame`` YAML > True.
+ Shape mirrors :func:`get_home_automode_visibility` so Terraform
+ overrides land the same way.
+ """
+ raw = os.environ.get("AGNES_HOME_SHOW_STATUS_FRAME")
+ if raw is None:
+ raw = get_value("instance", "home", "show_status_frame", default=True)
+ if isinstance(raw, bool):
+ return raw
+ return str(raw).strip().lower() not in ("0", "false", "no", "off", "")
+
+
def get_instance_name() -> str:
return get_value("instance", "name", default="AI Data Analyst")
diff --git a/app/web/router.py b/app/web/router.py
index b31a11d..fd089f2 100644
--- a/app/web/router.py
+++ b/app/web/router.py
@@ -693,6 +693,20 @@ async def home_page(
news = NewsTemplateRepository(conn).get_current_published()
news_intro = news["intro"] if (news and news.get("intro")) else ""
+ # Homepage status frame (Last sync, Sessions, Prompts, Tokens, Projects).
+ # Gated on (a) operator flag instance.home.show_status_frame /
+ # AGNES_HOME_SHOW_STATUS_FRAME (default on), AND (b) the user being
+ # onboarded — first-day users see a clean install-hero before zero-value
+ # stat cards. When either gate is closed we skip the DB read entirely.
+ from app.api.me import compute_home_stats
+ from app.instance_config import get_home_status_frame_visibility
+ status_frame_enabled = get_home_status_frame_visibility()
+ home_stats = (
+ compute_home_stats(conn, user, "24h")
+ if (status_frame_enabled and onboarded)
+ else None
+ )
+
# Single template renders both states. The post-onboarding view keeps
# the install-steps + connector prompts + auto-mode card visible —
# they stay relevant for adding a second machine, a missing connector,
@@ -706,6 +720,8 @@ async def home_page(
onboarded=onboarded,
is_admin=is_user_admin(user["id"], conn),
news_intro=news_intro,
+ home_stats=home_stats,
+ status_frame_enabled=status_frame_enabled,
)
return templates.TemplateResponse(request, "home_not_onboarded.html", ctx)
diff --git a/app/web/templates/_home_stats.html b/app/web/templates/_home_stats.html
new file mode 100644
index 0000000..c42a962
--- /dev/null
+++ b/app/web/templates/_home_stats.html
@@ -0,0 +1,205 @@
+{# Homepage status frame — five counters with a 24h/7d window toggle.
+
+ Initial paint is server-rendered from `home_stats` in ctx (no
+ spinner, no FOUC). The toggle JS calls GET /api/me/home-stats?window=
+ and rewrites the card values in place. #}
+
+
+
+
+
+
Status
+
+
+
+
+
+
+
+
+
Last sync
+
+ {% if home_stats.last_pull_at %}—{% else %}never{% endif %}
+