Skip to content

Files & git ​

Every session pane carries a file browser and a git view, both rooted at that session's own working directory. They dock beside the terminal on a desktop and open as a full-screen sheet on a phone.

Both panels can change things now — upload and edit files, stage, commit, push — and both are built around one fact: the agent in the terminal writes to the same tree you are looking at. Every change you make from a panel is bound to what you were shown, and is refused rather than guessed when that has moved.

FILES ​

A browser over the session's working directory: navigate, open a file, read it, edit it.

Its limits exist because a working directory can be a monorepo:

BoundValue
Entries listed per directory2000
Directory scan budget1500 ms
Maximum file read1 MiB

Scanning is parallel but capped — 8 workers overall, 3 per root — so browsing a large tree cannot starve the event loop that is also pumping your terminal.

Images, video and audio ​

Open an image, a video or an audio file and the viewer shows it with the browser's own player instead of saying it is binary:

KindTypes
ImagePNG, JPEG, GIF, WebP, AVIF, BMP, ICO, SVG
VideoMP4 / M4V, WebM, Ogg (OGV), MOV
AudioMP3, Ogg / Opus / OGA, WAV, FLAC, M4A / AAC, WebM audio

The strip above it names the type, the size in pixels or the length, and the file size. Images open fitted to the viewer — 1:1 shows them at full size — on a checker so transparency is visible. Video and audio stream, so a long recording seeks without downloading first.

An SVG is both a picture and text: it opens on its picture, and Source shows the text in the editor, where you can edit and save it as usual.

Download is on every binary file, previewable or not. A file the browser cannot show (an MKV, say) says so and offers Download; one it cannot decode (an MP4 in a codec it lacks) says Can't play here rather than showing a broken player.

The viewer streams at most four files at a time. A fifth says Busy and tries again on its own as soon as one finishes.

Over Home Free the file has to come through the tunnel, so previews and downloads are limited to 25 MB. A larger file says so; open BattleLab on the box's own address to play or download it. A file under the limit can still time out on a slow link — the viewer says so and offers Retry.

Uploading files ​

Upload into this folder, in the FILES panel's header, offers Files… and Folder…; a folder keeps its structure. You can also drop files or a folder straight onto a directory in the tree — the upload lands in the directory under the cursor, not wherever the panel happens to be. A browser that cannot pick a folder (iOS Safari has no folder picker) shows Folder… disabled with the reason instead of quietly flattening it.

A drop is checked against the limits below before a byte moves. A file that breaks a per-file rule gets its own row with the reason — TOO LARGE, for example — and the rest still go; a drop over the file-count or total-size limit is refused as a whole, with the limit named. The upload queue then shows every file's own state: QUEUED, SENDING, DONE, or the reason it did not land.

BoundValue
One file25 MiB
One drop500 files, 250 MiB in total
Folder depth32 levels
One name255 bytes
An idle dropexpires after 15 minutes without activity
Drops in flight8 per signed-in session, 64 across the server

Uploaded files are created with mode 0644, narrowed by the service's umask — never executable, whatever the file was on your machine. The bytes are counted as they arrive and written straight to the destination, and a refused or failed upload leaves nothing behind.

When the name is taken ​

Nothing is overwritten without asking. The dialog names the file and offers:

  • Skip — leave the existing file alone. Esc does the same.
  • Keep both — save the upload under a free name, such as report (2).pdf.
  • Replace — put the upload in place of the existing file.

With more files still to come, Apply to the remaining files answers once for all of them.

Replace checks twice. The existing file is fingerprinted — its content included — when you choose, and again just before the new bytes take its name; if the agent changed it in between, it is not replaced. A file over 64 MiB cannot be fingerprinted cheaply enough to be safe, so replacing one is refused: replace it in the session instead.

What an upload refuses ​

RefusedWhy
Anything inside git metadata — a .git folder, a repository's git directory wherever it lives, or a folder shaped like onehooks/ and config are code the agent's next git command runs
A file named HEAD, or objects/refs into a folder that already holds oneEither would turn an ordinary folder into a git repository
A symlink anywhere on the pathAn upload never follows one
The editor's recovery storeThe editor's save path acts on what is kept there
An absolute path, .., a backslash or a control character in a name
A full diskReported as such; the partial file is removed

If this platform cannot support the panel's containment checks, the panel is disabled and says why, rather than falling back to weaker ones.

Editing a file ​

Open a text file and press EDIT. The viewer is a full code editor themed on BattleLab's own colours: syntax highlighting, search (Ctrl/Cmd+F), undo, bracket matching. SAVE — or Ctrl/Cmd+S — writes the file.

The viewer is a dialog, so Tab moves focus rather than inserting a tab; indent with Ctrl/Cmd+] and outdent with Ctrl/Cmd+[. Closing the viewer, pressing Esc or navigating away with unsaved edits asks before discarding them.

When a file stays read-only ​

The viewer shows the file and says why it cannot be edited. A file is editable only when every one of these holds:

ConditionWhy
At most 1 MiBOnly the first 1 MiB is loaded; saving that would cut the file short
Valid UTF-8A byte that is not UTF-8 cannot survive the round trip unchanged
One line-ending styleLF or CRLF is kept exactly as it was, which needs one to keep; a BOM is kept too
One nameA hard-linked file would split from its other names — including the copy a refused save keeps in the recovery store; delete that copy to edit the file again
Owned by you
Not git metadata.git/config and hooks are code the agent's next git command runs, and a .git file tells git where a repository is
Not a kept previous versionThose open read-only (see below)
On a filesystem that supports file leases, the same one as the recovery storeHow a save knows nobody else has the file open

When a save is refused ​

Nothing is written, and your edits stay in the editor.

  • Changed on disk — the file changed after you opened it, usually because the agent wrote it. Switch between MINE and ON DISK to compare, then RELOAD FROM DISK, or OVERWRITE WITH MINE once you have looked at the version on disk.
  • Open in another process — another program has the file open. BattleLab asks the kernel (with a file lease) instead of guessing, so this includes a reader such as tail -f. Close it and save again.
  • Opened during the save — another program opened the file while it was being replaced. The original is put back before the save reports.
  • Permissions cannot be kept — a save keeps the file's mode, group and access list (ACL). If the group or ACL cannot be carried over, the save is refused before anything changes.

Previous versions ​

Each save keeps the version it replaced in a recovery store — ~/.agent-sessions/edit-recovery, or wherever AGENT_SESSIONS_EDIT_RECOVERY points — and the viewer links to it after the save.

Kept versions are never removed automatically — they stay until you delete them. Removing one safely would need proof that no program is about to write into it, and no check can give that for a write that starts a moment later. So the recovery store grows with every save, and cleaning it up is yours. A save that is refused or interrupted puts the original back at its name and keeps it in the store under a second name, so the file opens read-only (two names) until you delete the kept copy. Removing that name automatically could lose what a program still writing into the file adds. Uploads cannot write into the recovery store.

Two limits, stated plainly

  • A save survives a crash or a power loss between any two of its steps: the original is either at its name or in the recovery store, and is put back on the next start. That holds as far as the filesystem honours fsync.
  • A program that looked the file up just before a save but only opened it after the save finished writes into the kept previous version, not into the new file. Those bytes are recoverable from the recovery store; they are not merged.

GIT ​

The GIT tab shows the branch name, ahead/behind counts, and the working tree's changed paths grouped into conflicts, staged, changes and untracked. Selecting a path shows either a unified diff with both old and new line-number gutters, or the file's current contents when a diff does not apply.

It can also act — fetch, pull, switch and manage branches, stage, discard, revert a staged file, commit (staged, selected or all) and push; each is described under Git operations below. Each refuses rather than forces — a diverged branch, a dirty tree, a missing upstream or an unresolved conflict is a stated refusal with the next step, never a flag applied for you.

The status list is capped at about 5000 entries and reports itself as truncated beyond that — it tells you it is incomplete rather than quietly showing a prefix. Every git invocation is bounded the same way: a 10-second timeout and a 4 MiB stdout budget enforced while reading, not after, so a pathological repository cannot hang or balloon the request.

Diff rendering has its own ceilings — 2 MiB / 50,000 lines of source considered, 20,000 lines or 512 KiB rendered — and a total cell budget beyond which a diff is reported as too large rather than attempted.

Why the caps are documented

These are the numbers you hit on a real repository, and a truncated view that does not say it is truncated is worse than no view. Each of these limits announces itself in the UI.

Git operations ​

Every git write from the panel runs against the real repository, one at a time per repository, and is bound to what the panel showed you: if the tree, the index or the branch has moved since, the operation is refused and asks you to refresh, rather than acting on something you did not see. When the panel can tell in advance that a control cannot run, it is disabled with the reason written on it; otherwise the server refuses it — a pull on a tree with uncommitted changes, for example — and the refusal shows in the tab as Git // Refused with the reason and the next step.

True of every operation:

  • If the agent in the session is running git at that moment, the panel says the repository is busy and changes nothing.
  • Network operations speak https and ssh only. A remote that is a folder on this machine, or that points back at this machine, is refused, and so is a repository that turns off TLS certificate verification. Network operations are bounded at 60 seconds, local ones at 30.
  • Repository hooks do not run from the panel. A repository's own clean and smudge filters do, exactly as they would for the agent's own git add or checkout.

Fetch ​

FETCH fetches the current branch's upstream remote, or the only remote. With several remotes and no upstream it refuses rather than picking one. It updates only the remote-tracking branches — no tags, and never a local branch.

Pull — fast-forward only ​

PULL fetches the upstream and fast-forwards the current branch to it. It never merges and never rebases, whatever the repository's pull.rebase says. It is refused when:

  • HEAD is detached, or the branch has no upstream;
  • there are unresolved conflicts;
  • there are any uncommitted changes — commit or discard them first, so nothing is left sitting on a new base;
  • the branch has diverged from its upstream — rebase or merge in the session, then pull again.

If the branch advances but the working tree cannot be brought up to match, the panel says both things: the pull is done, and the working tree needs sorting out in the terminal.

Switch, create and delete branches ​

The branch name at the top of the tab opens the branch menu:

  • a local branch switches to it;
  • a remote-tracking branch creates a local branch of the same name from it, and switches;
  • New branch… creates a branch from the current one, and switches;
  • Delete branch… lists the other local branches.

The menu is capped to the space under the branch name and the list scrolls inside that cap, so it fits the screen at any number of branches — on a phone, in landscape, and while an on-screen keyboard is open. New branch… and Delete branch… are pinned below the list rather than sitting at the end of it, so they stay reachable however far the list runs.

A filter sits above the list. Typing narrows the local and the remote-tracking groups together, by case-insensitive substring, and the count beside it reads 11 match // 40 total — so a branch that is filtered out is distinguishable from one that is not there at all. Enter moves focus to the first match and never switches branch, since a switch touches the working tree and one keystroke from a typed filter is too easy to fire by accident. Escape clears the filter first and closes the menu on the second press. The delete list filters the same way.

Switching and creating are refused while there are uncommitted changes, because git switch would silently carry them onto the other branch. If a change appears while a switch is running, the panel goes back to the branch you were on.

Deleting is git branch -d only. The current branch cannot be deleted, and a branch that is not fully merged is refused — there is no force option, which is why deleting does not ask for confirmation.

Stage and unstage ​

Rows in Changes and Untracked have Stage, rows in Staged have Unstage, and each group has Stage all or Unstage all for exactly the rows it shows. Staging is whole files, never hunks, and it stages the content the row showed — an edit that lands afterwards shows up as a new unstaged change instead of riding in unseen. Unstaging puts the last commit's version back in the index; on a branch with no commits yet, it removes the file from the index and leaves it in the working tree. Both are refused while there are unresolved conflicts, and one request carries at most 500 paths.

Discard — recoverable by object id ​

Discard appears only on rows in Changes — tracked files with uncommitted edits — and it is the one git operation that asks first: the confirmation names the repository, the branch and the session the panel is docked into.

Discard puts back the version the index holds, so for a file that is both staged and modified it throws away only the unstaged edit. Before a file is replaced, its current contents are written to git's object database, and the result lists their ids: recover any of them with git cat-file -p <id>.

It is refused for an untracked file — git has no copy to put back, so "discard" would be a plain delete — and when the file, or its staged version, changed after you were shown it.

Revert a staged file ​

The revert control on a staged row puts that file back to the last commit — index and working tree together. The versions it replaces are kept as git objects and their ids are listed afterwards (git cat-file -p <id> shows one). A file the last commit does not have is refused: reverting it would delete it. While it works it holds git's index, HEAD and branch locks, and checks again under them that the checkout, the last commit and the staged version are still the ones you were shown — so a switch or reset the session runs at that moment is refused by git, and if something moved just before, nothing is reverted. If putting a file back fails part-way, the result still lists every version already moved aside, names the files not yet put back, and leaves the index unchanged.

Commit ​

Type a message in the box at the foot of the tab and press COMMIT. With Staged chosen (see What a commit is made of) it commits exactly the staged set the panel showed; if anything was staged or unstaged since, it is refused, so a file the agent staged cannot ride along unseen. The author is the git identity configured on the host — with none, the commit is refused and asks you to set user.name and user.email in the session.

There is no amend, and no hook or signing program runs. The first commit on a new, empty branch works. A commit is also refused when:

  • nothing is staged, or there are unresolved conflicts;
  • HEAD is detached — create a branch first, or the commit would not be reachable from one;
  • the repository has more changes than the panel can list, so the commit would include files it never showed you — commit in the terminal instead;
  • the branch moved while the commit was being written.

A Staged commit holds Git's index lock from the final verification until the commit is recorded. A concurrent git add gets Git's usual lock error; retry it after the commit finishes. If another Git command already holds that lock, the panel refuses without committing. The commit is built from a private copy of the verified index, so unstaged worktree edits stay out of it. The reported file count comes from the recorded changes (a rename counts once). If the status refresh fails afterward, the panel still returns the commit ID and the refresh error.

A message can be up to 16,384 characters.

What a commit is made of ​

The switch above the commit message picks one of three:

  • Staged — the index as it stands, exactly as git commit would.
  • Selected — only the paths you ticked, as they are now, including any unstaged change to them. The rest of the index is left alone, so something you staged earlier stays staged.
  • All — every staged and changed path. An untracked file joins only if you ticked it, so a stray build artefact is never committed because nobody unticked it.

Selected and All commit against the rows you were shown: if a ticked file changes, someone commits, the checkout switches branch, or a merge or conflict appears between the panel's last refresh and your click, the commit is refused and nothing is written. While it writes the commit and updates the index, the panel holds git's own index lock — and, while it brings the index up to the new commit, git's locks on HEAD and the branch too — so a git command the session runs in that moment (commit, switch, add, reset) fails with git's usual "index.lock exists" or "cannot lock ref" error instead of racing it — run it again afterwards. If another git is holding the index lock for more than a moment, the commit is refused and nothing is written; if HEAD or the branch is locked just after the commit is made, the commit stands and the index is left pending. If the checkout was pointed at another branch in the instant before the commit was written, the commit is left where git wrote it rather than taken back: the panel tells you which branch holds it and names the commit, and the index is left alone. The same goes for a branch that was itself turned into a symbolic ref to another branch in that instant — git writes the commit to the branch it points at, and that branch keeps it. Undoing either is yours to do in the session, where you can see the branches involved. The panel will not move a branch it cannot prove is still the same ref it checked a moment earlier, because doing so has been shown to overwrite another program's branch. Branches that share a name with a tag, and branch folders that are symbolic links, are handled by the branch's full name and refused rather than followed. A checkout whose HEAD reaches its branch through another symbolic ref is refused before anything is written. Once the commit is made, nothing that happens afterwards turns it into a refusal that hides it: if the checkout cannot be checked again, the commit stands and the index is left pending.

Two more refusals, both so your index can never end up disagreeing with a commit in a way the panel cannot show you:

  • A file with a staged version and a different change on top — stage it first (or commit Staged), then commit it.
  • Half of a staged rename — a rename is committed with both its old and new name, never as a copy.
  • A file or branch whose name is not valid UTF-8 — git can hold such names, but the panel cannot tell one from a different file or branch that looks the same, so it never writes to them. Use the session's terminal.

Index pending ​

A Selected or All commit happens in two steps: the commit, then bringing your real index up to it. If the second step cannot finish — git is busy, a file was staged again in between, or the checkout switched to another branch, the branch moved on, or a merge started — the commit still exists, and the tab shows INDEX PENDING for the paths the index has not caught up on. SETTLE refuses while a merge or a conflict is in progress, and holds git's index, HEAD and branch locks while it works, so a reset or switch cannot slip in between its check and its write. If the index was updated but could not be confirmed as written to disk, the result says so; the update itself stands. Until then those paths read as staged changes that would undo the commit.

That state looks exactly like a reversal you staged on purpose, so the panel never settles it for you. SETTLE brings the index up to the last commit for those paths and leaves anything staged after it alone. A Staged commit made while the banner is showing asks first, naming the paths.

Revert a commit ​

RECENT COMMITS, under the change groups, lists the branch's last 30 first-parent commits and marks the ones its upstream does not have yet. REVERT on one creates a new commit that undoes it — history is not rewritten and nothing is pushed.

History refreshes after a write and when the displayed branch or last commit changes. If the history no longer matches them, the panel offers Refresh instead of actions on stale rows.

It needs a clean working tree, so a revert never lands on top of uncommitted work. It is refused for a merge, for the branch's first commit, and for a commit that changes a symlink, a submodule or a file mode, or replaces a file with a directory (or vice versa) — those are terminal work. A revert that would conflict with later commits is refused before anything is written, and if the branch moves between opening the confirmation and confirming it, nothing is committed. Switching to another branch is also refused when the two branches point at the same commit: the confirmation binds both the branch and the commit. The merge is computed without the repository's own configuration, so a merge driver the repository defines never runs.

After the commit is made, each file is updated only if it still holds the content from before the revert; a file someone changed in the meantime is left exactly as it is, and the tab names it. A file the revert deletes is saved as a git object first, and its id is listed.

A revert is also refused while a merge, cherry-pick or revert is in progress, or if the session switches branch just before the commit is written. If the checkout switches, or a file goes into conflict, after the commit is made, the new commit is kept and reported, but that branch's index and files, or the conflicted file, are left exactly as they are. If the revert stops part-way for any other reason, the tab still shows the new commit, the files it could not update, and every version already saved. An incomplete restoration can leave a pathname absent after preserving its previous bytes; inspect the working tree and the listed recovery copies. SETTLE retains those copies and any applied index result if its worktree phase fails, and reports a directory blocking a committed file as pending instead of claiming it finished. While the revert records its commit and updates the index and files, it holds git's own index lock, so a git switch, git commit or git merge run in the session at that moment fails with git's ordinary "index.lock exists" error instead of landing halfway through; if git itself is busy for more than a couple of seconds, the revert is refused and nothing is committed. From the moment its commit is recorded until its files are written it also holds the locks on HEAD and the branch, so a git reset --soft or a command that repoints HEAD fails with git's "cannot lock ref" error; if another git holds one of those at that moment, the commit is kept and the index and files are reported as not yet updated. If the session points HEAD at another branch in the instant just before the revert's commit is recorded — which git cannot refuse, because recording the commit needs HEAD's lock itself — the revert stays where git recorded it, and the tab reports its commit id with the index and files still pending. The same applies when HEAD moves just after, or a branch is redirected to another branch during publication: the panel leaves the index and files untouched and never moves a ref back automatically. Inspect the reported commit and preserve uncommitted work before deciding how to recover in the terminal. Reverting is refused before publication when core.logAllRefUpdates is disabled, because the panel needs that record to identify where its commit landed. If the index was updated but writing it to disk could not be confirmed, the tab says so rather than reporting a plain success. When the tab cannot check whether files are still behind the last commit — there are too many, or they are too large, to compare — it says the working-tree state is unknown rather than showing nothing.

Push ​

PUSH → remote pushes the current branch to the branch of the same name on a remote the server chooses, and the button names that remote before you press it. The panel asks GET /api/git/push-target, which picks:

  1. the branch's upstream remote;
  2. otherwise the repository's remote.pushDefault;
  3. otherwise the only remote.

origin is never assumed. With several remotes and nothing to choose between them, the tab lists the candidates as buttons so you can pick one. A branch with no upstream gets one on its first push, and the tab says so beforehand.

What you were shown is what gets pushed: the preflight pins the destination URL and the exact commit, and if either has changed by the time you press — the agent committed again, or the remote's push URL was repointed — nothing is sent and the panel asks you to refresh. There is no force push.

If the remote accepted the push but the local bookkeeping afterwards did not finish, the panel says so plainly: the remote has the commit, and pushing again is safe but unnecessary.

Verified against

Commit 8b1c66b — src/agent_sessions/files.py § FILES_MAX_ENTRIES, FILES_SCAN_BUDGET_MS, FILES_MAX_READ, capabilities; src/agent_sessions/fileedit.py § MAX_EDIT_BYTES, recovery_dir, resolve_pending, read_file, refuse_recovery_store; src/agent_sessions/filewrite.py § MAX_FILE_BYTES, MAX_BATCH_FILES, MAX_BATCH_BYTES, BATCH_IDLE_TTL_S, MAX_LIVE_BATCHES, MAX_LIVE_BATCHES_TOTAL, MAX_DEPTH, MAX_COMPONENT, FILE_MODE, _REPLACE_MAX, validate_relpath, refuse_git_metadata, open_destination; src/agent_sessions/gitpanel.py § GIT_MAX_ENTRIES, GIT_TIMEOUT_S, GIT_MAX_STDOUT, DIFF_MAX_*; src/agent_sessions/gitwrite.py § NET_TIMEOUT_S, LOCAL_TIMEOUT_S, GIT_ALLOW_PROTOCOL, MAX_PATHS, MAX_MESSAGE, git_fetch, git_pull, git_switch, git_branch_delete, git_stage, git_discard, git_commit, git_commit_paths, git_settle, git_log, git_revert, resolve_push, push_target, git_push; web/src/components/files/{UploadControl,UploadQueue,uploadPlan,GitTab,BranchMenu,gitOps}.

Built from v0.19.4-323-g62490f57 · 62490f5 · ahead of last publish: unknown