CityRP Documentation Repository

Claude Code on the web sessions

Last updated

How a cloud session reaches the live repository, and what to do when it hasn't.

Setting up a cloud environment, for Claude or for Cursor, is in cloud-agents.md. Cursor cloud agents run the same script from .cursor/environment.json's start command; everything below applies to them too, except the Claude-specific parts of step 5.

This repository lives on Gitea. Claude Code on the web can only seed a session from a GitHub source — a self-hosted Gitea URL is rejected at session creation — so a cloud session has to be born on a GitHub mirror and then moved off it.

That mirror is a Gitea push mirror to the GitHub repository at limelight-development/cityrp2, on an 8-hour interval with no sync on commit, so it can be up to eight hours behind Gitea; tea api '/repos/limelight-development/cityrp2/push_mirrors?limit=50' shows its interval and last sync. It is force-synced one way, and nothing there is read by anyone: the mirror is a bootstrap detail and nothing else. origin is Gitea; there is no second remote.

What happens at session start

One SessionStart hook is registered in .claude/settings.json, and it moves the checkout onto Gitea.

Moving the checkout onto Gitea

.claude/hooks/session-start.sh does the move:

  1. Skips entirely unless CLAUDE_CODE_REMOTE=true (set by Claude) or CURSOR_CLOUD_AGENT=true (set by us as a Cursor secret), so it never touches a local checkout.
  2. Registers a git credential helper for $GITEA_INSTANCE_URL, in global git config so it also covers sibling repositories cloned during the session. The helper reads $GITEA_TOKEN when git calls it, so the token is never written to disk, and no tea login is involved.
  3. Repoints origin from the GitHub mirror to Gitea — only if origin matches the mirror URL exactly. Any other remote is left alone.
  4. Fast-forwards the checkout onto Gitea's master — the master branch itself when that is what is checked out, and otherwise a work branch that still holds no commits of its own. It refuses if tracked files are dirty, or if the branch carries work of its own.
  5. Drops the work branch's remote-tracking ref when Gitea has no such branch, and points origin/HEAD at master, so that anything measuring "unpushed" measures against a real base instead of the mirror's sync lag.

It runs synchronously. Async would let the agent start reading before the swap lands, which is the one failure this hook exists to prevent.

Step 4 covers work branches because of when the session's branch is cut. The harness branches from the GitHub mirror's head, before this hook has run, so a session starts however far behind the push mirror happened to be lagging — on an 8-hour interval, a long way. A branch in that state holds no commits of its own, so every commit it can reach is already on Gitea's master: fast-forwarding it loses nothing and cannot conflict. The moment it carries a commit of its own it is real work, and the hook leaves it alone. Rebasing that onto master is a judgement call for whoever is working in it, not something a startup hook should do unasked.

Step 5 cleans up after that same moment. A session's work branch is also declared as its outcome branch — the web UI does it when you start a session, create_session does it when passed outcome_branch — and the harness seeds refs/remotes/origin/<branch> from the mirror's head without that branch existing on any remote. Once origin is Gitea the ref can never be fetched again, so it stays frozen exactly however far the mirror was lagging. Anything then measuring "unpushed" against it reads that lag back as commits to push, on a branch that is not there: Claude Code's own stop hook does precisely this, once per turn, telling the agent to push work that does not exist — and an unattended agent that believes it will create a junk branch on Gitea. So the hook drops the ref, but only when Gitea genuinely has no such branch, leaving a resumed session whose branch was pushed with a real ref to measure against. It then points origin/HEAD at master, so the measurement lands on a true base and stays correct rather than merely silent.

It fails the session rather than leave you on mirror code. If it cannot get the checkout onto Gitea — no credentials, or Gitea unreachable — it exits non-zero with a FATAL message. A session quietly editing stale code and pushing it somewhere it evaporates is worse than no session at all.

Reading the output

Every exit path prints a [gitea-remote] line into the session's context, because a session that silently kept mirror code is otherwise indistinguishable from one that worked.

Line Meaning
master at <sha> - <subject> Worked. You are on live Gitea code.
tea: using ephemeral env login … Normal. tea is authenticated from the environment; an empty tea login list is expected, not a fault.
origin switched: GitHub mirror -> … Remote moved this session (normal on a fresh session).
origin already points at Gitea. Already moved, e.g. a resumed session.
'<branch>' held no commits of its own and was behind master; The work branch was cut from the mirror before it had synced. Fast-forwarded onto Gitea's master; the line after it names the commit landed on.
on '<branch>', already at master's head. Work branch is current. Nothing to do.
on '<branch>', not 'master'; fetched but leaving HEAD alone. The branch carries commits of its own, so the hook will not move it. Rebase it yourself if you want master's latest.
dropped the mirror's tracking ref for '<branch>' Normal on a fresh session. The ref the harness seeded for the outcome branch pointed at the mirror, and Gitea has no such branch, so it was removed.
FATAL: this checkout still holds the GitHub mirror's stale code Session aborted. Fix the environment; do not work from this checkout.
WARNING: uncommitted changes to tracked files On Gitea, fetched, but HEAD deliberately left alone.
WARNING: '<branch>' is behind master but would not fast-forward The branch should have moved and didn't. Check the tree before trusting it.
WARNING: cannot fast-forward master On Gitea, but local history diverged — investigate, don't force.
WARNING: could not drop the stale tracking ref for '<branch>' The ref survived. Your code is fine, but a stop hook may keep reporting the mirror's sync lag as unpushed commits; clear it with git update-ref -d refs/remotes/origin/<branch>.
WARNING: could not point origin/HEAD at master Nothing is left to measure "unpushed" against, so real commits on the work branch may go unflagged.
WARNING: could not fetch master from Gitea Already on Gitea from earlier; working from what is already local.

A WARNING means the remote is right but HEAD was not moved — check what commit you are on before trusting the tree. A FATAL means the session should not be used at all.

What a cloud session cannot do here

This repository's gamemode code is exercised by a Garry's Mod server, not by the container. The test suites boot one under Docker through .gitea/scripts/gluatest.sh and need the private cityrp2-cfg checkout for its binary modules, so a cloud session runs the diff-scoped gates and the Python suites and leaves the rest to CI:

python3 .gitea/scripts/lint-diff.py --base origin/master gamemodes/cityrp_2_by_limelightgaming
python3 .gitea/scripts/standards-diff.py --base origin/master
python3 -m unittest discover -s tools/sweep -p "test_*.py"
python3 -m unittest discover -s .claude/skills/wave -p "test_*.py"

Environment keys

Important

These are set on the cloud environment itself: Claude Code environment variables, or Cursor secrets.

Key Purpose
GITEA_INSTANCE_URL Base URL of the Gitea instance, e.g. https://gitea.limelightgaming.net. The hook and tea both key off this.
GITEA_TOKEN Gitea access token. Used by tea for its own API calls, and by the git credential helper for fetch and push.
CURSOR_CLOUD_AGENT Cursor only: true. Cursor sets no variable of its own that tells a cloud agent apart from a local machine.

tea needs no tea login: 0.16.0 builds an ephemeral login from those two variables, which is why tea login list is empty in a working session.

Working in a cloud session

  • origin is Gitea. Push branches to it as normal. There is no separate gitea remote.
  • Never push to GitHub. The mirror is force-synced from Gitea, and anything landed there is overwritten on the next sync, silently. If git remote -v shows github.com, the hook did not run — stop.
  • Cloning a sibling repo (cityrp2-cfg, web-website, standards, infrastructure) needs no extra setup — the hook's credential helper is global and host-scoped, so git clone https://gitea…/… just works. A could not read Username here means the hook did not run; do not fix it with tea login add, which writes the token to ~/.config/tea/config.yml in plaintext.
  • Pull requests go through tea, not gh (AGENTS.md).
  • /wave spawns sibling cloud sessions here rather than desktop task cards, through the claude-code-remote MCP server's create_session, one per child it dispatches whatever skill that child's state calls for. Each sibling is seeded from the GitHub mirror — the only source Claude Code on the web accepts — and inherits this session's environment, so the hook above runs for it too and moves it onto Gitea before it starts work. Each is given an outcome_branch, because claude.ai files a session under its repository by the outcome record and not by source_url: a sibling spawned without one lands under "Other". The name must be one Gitea does not have, or step 5 above will keep a ref pointing at the mirror.
  • Issues are Gitea issues — see issue-tracker.md and the gitea-issues skill, which exists because tea issues list --labels silently ignores org-level labels and returns everything. Filter through the org-wide search endpoint rather than trusting that flag.

Changing the hook

The hook ships in this repository, so it mirrors to GitHub automatically and a fresh session picks up whatever is on master. Note the bootstrap order: the harness reads .claude/ from the mirror checkout before anything has been swapped, so a hook change only takes effect for sessions started after the mirror has synced it.