Skip to content

feat(mirror): project files can stay on the client while agents run on the host - #1

Merged
malekelkssas merged 37 commits into
mainfrom
feat/project-mirroring
Sep 3, 2026
Merged

malekelkssas merged 37 commits into
mainfrom
feat/project-mirroring

Conversation

@malekelkssas

Copy link
Copy Markdown

What Changed

Adds project mirroring: in remote mode the provider CLI runs on the host, but project files can now stay on another environment (the origin). The host materializes a git-backed mirror of the origin's folder, agents run against the mirror at full local speed, and a sync protocol keeps both sides in agreement over the existing connection layer.

  • Contracts: ProjectOrigin on projects, mirror.* RPCs, mirror:sync scope, projectMirroring capability, t3.json mirror.include allowlist.
  • Server: GitSync snapshot/bundle/three-way-apply primitives, signed GET/PUT bundle route, MirrorService (host broker + sync state machine, restart-safe queued apply-backs), MirrorAgent (origin side), turn-start sync gate with an explicit stale-run override, post-checkpoint apply-back.
  • Web: capability-gated "Files live on another machine" creation flow and a mirror status chip with Sync now and conflict listing.
  • Docs: internals section in remote.md, user guide, glossary entries.

Why

Lets a project's real working copy stay on a laptop (uncommitted edits, untracked files, gitignored-but-allowlisted files like .env) while an always-on machine runs the agents, syncing automatically before and after every turn instead of requiring the files to live on the host.

Test plan

Verified end-to-end with a two-device setup (host + origin): pairing, project seeding (dirty edits, untracked files, mirror.include entries), pre-turn push, post-turn apply-back, manual sync, origin-offline stale-run fallback with queued apply-back replay on reconnect, and conflict handling (user's local edit wins, agent's version stays reachable in git history). Also covers regression checks: gitignored files don't leak beyond the allowlist, non-git folders are rejected at setup, and non-mirrored projects behave unchanged.

Automated suites: pnpm vp test run src/mirror (GitSync + MirrorService end-to-end) and pnpm vp test run src/orchestration/Layers/ProviderCommandReactor.test.ts (turn gate).

Nardy11 and others added 15 commits August 9, 2026 17:18
…n the host

In remote mode the provider CLI runs on the host, but project files also
had to live there. This adds project mirroring: the host materializes a
git-backed mirror of a folder that lives on another environment (the
origin), agents run against the mirror at full local speed, and a sync
protocol keeps both sides in agreement over the existing connection layer.

- Contracts: ProjectOrigin on projects, mirror.* RPCs, mirror:sync scope,
  projectMirroring capability, t3.json mirror.include allowlist.
- Server: GitSync snapshot/bundle/three-way-apply primitives, signed
  GET/PUT bundle route, MirrorService (host broker + sync state machine,
  restart-safe queued apply-backs), MirrorAgent (origin side), turn-start
  sync gate with an explicit stale-run override, post-checkpoint apply-back.
- Web: capability-gated "Files live on another machine" creation flow and
  a mirror status chip with Sync now and conflict listing.
- Docs: internals section in remote.md, user guide, glossary entries.

Implemented by Claude Fable 5 via Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mirror.createPeerCredential required access:write, which only admin-scoped
(startup) pairings carry, so a normally paired client could not finish
mirror setup. The endpoint mints a token scoped to mirror:sync only, so
orchestration:operate - the scope that already permits running agents -
is the right gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirroring is built on git, but the folder itself didn't need to be a
repository already — a plain folder now gets one initialized in place on
attach instead of being refused outright, with GitSync's seed/apply
paths fixed to handle the resulting unborn-HEAD, no-branches state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds Settings → General → Developer tools → React Grab overlay, letting
contributors hover T3 Code's own UI and copy component stack/source
locations for an agent. Gated so it can never reach a shipped build:
the loader script is injected only by vite dev (apply: "serve"), the
package stays a devDependency, and both the settings row and the hook
bail outside import.meta.env.DEV — enforced by reactGrabBoundary.test.ts.

The loader (reactGrabDevEntry.ts) has to run before react-dom evaluates
so React Grab's fiber instrumentation can see React mount, which a
same-module dynamic import can't guarantee; a separate injected
<script type="module"> ahead of main.tsx's own tag does. It also works
around react-grab's init({enabled:false}) returning a permanently inert
stub instead of a toggleable instance, and installs a live stylesheet
so turning the setting off fully hides the overlay instead of leaving
react-grab's own collapsed-toolbar affordance in the page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ving empty gitlink stubs

Gitlinks (mode 160000) were never followed by the mirror's bundle/read-tree
pipeline, so submodules and unregistered nested repos always mirrored as
empty directories. Detect gitlinks via `git ls-tree`, and mirror each one
through the same seed/sync/checkout primitives already used for the
superproject, whether or not the path is registered in `.gitmodules`. A
gitlink with no local copy anywhere on the origin is a per-path warning,
not a failed sync. Includes a fix so a plain resync also picks up gitlinks
that predate this feature (previously only newly-changed gitlinks were
retried), plus two git-behavior bugs found along the way: isRepository
misdetecting an uninitialized submodule path as already a repo, and
`git fetch` triggering unwanted submodule auto-recursion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Setup Worktree script depends on .env and infra/relay/.env, which
are gitignored and so are skipped by mirror sync by default.
Adds a "Sync gitignored env files" setting in project settings for
mirrored projects, storing a mirrorIncludeIgnoredFiles flag alongside
the existing defaultThreadEnvMode per-project override. The host sends
the resolved extra include patterns (.env, .env.local, .env.*.local)
to the origin on mirror.connect; both sides union them into their
sync-snapshot include paths so no t3.json edit is required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The mirror.connect stream is long-lived, so a project's
mirrorIncludeIgnoredFiles toggle only reached the origin at the next
reconnect, not the next sync. Send a settings-updated event with the
freshly resolved extra include paths right before every directive, so
toggling the setting takes effect on the very next sync/apply-back
(root or submodule) without needing the connection to drop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`git add` is fatal on an unmatched pathspec and then adds NONE of its
arguments, so one missing entry (.env.local when only .env exists)
silently dropped every include path from the snapshot — gitignored env
files never synced. Add each include path in its own git add
invocation so a missing pattern only skips itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Plain `.env` pathspecs only match at the repo root, but monorepos keep
their env files in per-app subfolders (apps/api/.env), so the toggle
synced nothing for exactly the projects that need it most. Switch the
patterns to `:(glob)**/...` pathspecs and exclude node_modules matches
from every include force-add, since dependency trees are never synced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A stale mirror link (host project deleted or detached) made the origin
agent retry mirror.connect every 15 seconds forever, flooding the log
with MirrorProjectNotMirroredError. That answer is definitive, so stop
the agent instead and log a single warning. The link row is kept; a
real re-attach restarts the agent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Deleting a mirrored project now sends link-revoked to a connected
  origin (previously the directive had no producer), so the origin
  drops its mirror_links row and token instead of leaving a stale
  leftover; the host's sync watermark is cleared too.
- New mirror.listLinks RPC plus a "Shared folders" section in
  Connections settings: see every folder a device shares to a host and
  remove leftovers via the existing mirror.detach.
- Seed/sync transfer progress: the bundle upload route counts received
  bytes (with Content-Length totals) into MirrorBundleTransfer, the
  status stream republishes once a second while a transfer is in
  flight, and the mirror chip shows "Seeding · 30%" with a progress
  bar in the popover.
- Mirror chip popover shows copyable origin and host mirror paths.
- Project settings shows a "Mirrored project" info row (origin
  machine, path, live state); the Danger-zone copy explains deletion
  also disconnects the origin.
- Sidebar thread rows show a mirror glyph on mirrored projects and the
  hover tooltip names the machine holding the files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	apps/web/src/components/chat/ChatHeader.tsx
#	apps/web/src/components/settings/ProjectSettingsPanel.tsx
…ctness, and consistency

Fixes raised by Macroscope's review of pingdotgg#6157:

Security:
- Bind mirror.connect/mirror.respond to the caller's authenticated peer
  subject so a peer token minted for one project can't act as the origin
  for another (ws.ts, MirrorService.ts).
- Reject project ids that would let a mirrored project.create escape
  mirrorsDir via path traversal (Normalizer.ts).
- Reject submodule directive paths that escape the project root on the
  origin agent (MirrorAgent.ts).
- Stop leaking the signed single-use bundle URL into user-visible error
  detail on transport failures (MirrorAgent.ts).
- Check the bundle transfer token's direction before consuming it, so a
  wrong-method request can't burn the one legitimate use (MirrorBundleTransfer.ts, http.ts).
- Require the stale-run retry to resend the exact failed message, not just
  any message on the same thread within the offer window (ProviderCommandReactor.ts).

Correctness:
- Stage (snapshot + persist) a post-turn apply-back synchronously under the
  project lock before turn completion is signaled; only the network
  delivery to the origin is backgrounded. Fixes a race where a
  fast-following turn's ensureFresh could overwrite the mirror before the
  completed turn's edits were captured (CheckpointReactor.ts, MirrorService.ts).
- Fix reconnect teardown ordering so a superseded connection's in-flight
  requests are failed immediately instead of leaking until their timeout (MirrorService.ts).
- Remove a staged submodule bundle on every non-success exit, not just the
  success path, to stop bundle files accumulating in staging (MirrorService.ts).
- Parse git ls-tree/merge-tree output with -z so paths with special
  characters aren't mishandled during conflict resolution, and handle
  submodule gitlink entries (mode 160000) alongside blobs (GitSync.ts).
- Propagate branch-update failures during apply instead of ignoring them
  while still marking the apply successful (MirrorAgent.ts).
- Guard against a double Enter-key submission creating duplicate mirrored
  projects (CommandPalette.tsx).
- Return early on every detach failure, not just non-interrupted ones, so
  an interrupted failure doesn't show a false success toast (MirrorLinksSection.tsx).

Other:
- Align the bundle transfer token TTL with the 30-minute seed timeout.
- Use Effect.catchTags consistently for single-tag recovery.
- Remove a docs contradiction about non-git folders.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 11, 2026
…ork-log warnings

The claude-agent-sdk build running here emits a command_lifecycle message
type ahead of what the pinned SDK's TypeScript types declare, so it fell
through the adapter's exhaustiveness guard into an opaque
"Claude SDK message 'command_lifecycle' — command_uuid: ... · state: ..."
runtime-warning row in the thread's work log. It's internal bookkeeping
with no T3 surface, so consume it deliberately like prompt_suggestion.
Fixes raised by Macroscope's follow-up review of pingdotgg#6157:

Security:
- Revoke any prior mirror-peer session for a project before issuing a new
  peer credential, so a stale token from a previous origin can't reconnect
  and displace the live connection (ws.ts).

Correctness:
- Fast-forward the checked-out branch via applyBranchUpdatesToCurrent in
  ingestBranchUpdates instead of just resetting the index — it was left on
  the old commit while the working tree had already moved (MirrorService.ts).
- Fail on staging-directory creation instead of swallowing it, so the
  bundle-transfer layer doesn't silently start in a broken state (MirrorBundleTransfer.ts).
- Delete the mirror link row at the point link-revoked is detected, not
  after the (now-failed) stream returns — the failure was short-circuiting
  past that check, so a revoked link retried with its dead token forever (MirrorAgent.ts).
- Show a failed-to-load state on shared folders instead of an
  indistinguishable empty state (MirrorLinksSection.tsx).
- Derive the deletion marker's OID width from a real oid in the same repo
  instead of assuming SHA-1, so SHA-256 repos don't break conflict
  resolution (GitSync.ts).
- Thread priorState through processGitlink's recursive call so nested
  submodules-of-submodules resolve their real prior oid instead of always
  re-seeding from scratch (MirrorService.ts).
- Base isMirrored on any checkout in the project group, not just the
  representative, so a mirrored non-representative checkout isn't hidden (ProjectSettingsPanel.tsx).
- Use monotonic time (not wall-clock) for the stale-run offer window so a
  clock correction can't extend or collapse it (ProviderCommandReactor.ts).
- Roll back the host-side project on any mirror setup failure after
  creation, so a broken origin folder doesn't leave an orphaned project
  behind on every retry (CommandPalette.tsx).

Documented as an accepted trade-off rather than fixed: the bundle-transfer
single-use token registry is in-memory only, so a restart within the TTL
window allows one replay of an already-consumed token.
…aw argv/stderr

args can carry a remote URL with embedded credentials (git remote add/set-url),
and this error's message flows into MirrorSyncFailedError.detail and from
there the RPC boundary, status, and logs. Keep args/stderr as structured
fields but derive message from bounded attributes (subcommand, root, exit
code, stderr length) only.
Keeps ProviderCommandReactor.test.ts within the no-manual-effect-runtime-in-tests
legacy baseline (70 occurrences), fixing the failing CI lint check on PR #1.
# Conflicts:
#	apps/web/src/AppRoot.tsx
#	apps/web/src/components/settings/settingsSearch.ts
#	packages/contracts/src/environment.ts
@github-actions github-actions Bot added the 📱 Native Change Changes the native fingerprint; merging blocks production OTAs until a new store build ships. label Aug 17, 2026
malekelkssas and others added 7 commits August 17, 2026 14:16
…merge

t3ProjectFile.test.ts was missing the mirror field this branch adds to the
project-file schema; DesktopClientSettings.test.ts was missing
reactGrabEnabled, a settings field added by the upstream merge.
The upstream merge added ReactGrabController to AppRoot, so it now renders
5 renderer-wide providers instead of 4.
- Move mirror-link revocation off project delete into a
  MirrorProjectDeletionReactor triggered by the project.deleted domain
  event, so it covers every dispatch transport (WS, HTTP, offline CLI),
  not just the WebSocket handler. It now also revokes the mirror-peer
  auth session so a disconnected origin's credential can't outlive the
  project.
- Fix mirrorCreatePeerCredential to issue the replacement session before
  revoking the old one, so a failed issue doesn't strand the origin
  without any working credential.
- Gate the mirror freshness check before first-turn branch rename/title
  generation side effects, so they can't run against a stale mirror.
- Resolve mirror-add's explicit relative path against the origin browse
  cwd instead of null, and await the host-project rollback (surfacing a
  toast on failure) instead of firing it and forgetting.
- Bound GitSyncCommandError's serializable fields (subcommand,
  stderrLength) instead of carrying raw argv/stderr, which could embed
  remote credentials.
- Close MirrorAgent's manager scope via a finalizer, and add the missing
  gap/motion-reduce classes flagged on ChatHeader and MirrorStatusChip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Fix listRemotes' regex to allow remote URLs/paths containing spaces
  (e.g. a mirror workspace under "My Project"), which previously fell
  through unmatched and caused setRemotes to re-add an existing remote.
- Resolve symlinks before the submodule path containment check, so a
  symlink inside the working tree can't be used to point a submodule
  seed/apply/sync operation outside the project root.
- Thread MirrorService/EnvironmentAuth as Effect requirements in
  revokeMirrorLinkAndCredentials instead of passing service instances as
  plain parameters, and mock them via Layer.mock in the test instead of
  `as unknown as` casts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…oring

MirrorLinksSection queried mirror.listLinks against every saved
environment, including ones running an older server without the RPC
method, which rendered a permanent "unavailable" row and broke the
version-skew contract CommandPalette already honors elsewhere. Filter
to environments that advertise projectMirroring before handing them to
the section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gg#6157

- Retry mirror-link/credential revocation a few times with backoff
  before falling back to a logged best-effort skip, since it's consumed
  exactly once off a hot domain-event stream and a transient failure
  previously dropped the revocation for good.
- Treat merge-tree exit code 1 with no listed conflict paths as a
  failure instead of silently applying a tree that may still contain
  raw conflict markers.
- Give the two path-copy buttons in MirrorStatusChip's popover distinct
  aria-labels (origin vs. mirror) instead of both saying "Copy link".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI's Check job flagged two no-manual-effect-runtime-in-tests lint
errors in the new reactor test. Switch to @effect/vitest's it.effect,
matching the convention every other Effect test file in this repo uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
malekelkssas and others added 10 commits August 20, 2026 15:53
…Sync

- ls-tree now runs with -r so a conflict path that's a directory on the
  preferred side (a file replaced by a folder of the same name) expands
  to every blob/gitlink beneath it, instead of being silently dropped by
  the blob/commit-only match and leaving merge-tree's synthetic subtree
  in the final result. Clears any bare-file entry at the conflict path
  first, since an index can't hold both a blob and nested entries there.
- ls-tree and update-index --index-info both run with -z, so a
  conflicted path containing a literal tab or newline round-trips
  correctly instead of being quoted/escaped or split across records.
- Added a test covering a file-vs-directory type conflict end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
applyBranchUpdates used allowNonZeroExit on `git worktree list
--porcelain` and never checked for truncated output, so a failing or
truncated discovery silently degraded to "no other worktrees have this
branch checked out" instead of blocking the ref update — the opposite
of the guarantee this check exists to provide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
diffGitlinks/discoverAllGitlinks took GitSync as a plain parameter
instead of acquiring it from the environment, so the git dependency
was invisible in their returned effects' requirements. Callers already
hold a resolved instance, so they provide it locally instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…otARepositoryError

Both errors derive their message from a detail string that production
call sites built as `cause.message`, discarding the underlying error
entirely. Added an optional cause: Schema.Defect() field (the same
pattern already used elsewhere in packages/contracts) and threaded the
real cause through at every call site that had one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
revokeLink deleted mirror_sync_runtime without holding the same
per-project lock every sync/apply-back core runs under, so an in-flight
operation's later saveRuntime call could recreate the row right after
it was cleared — letting a subsequent relink reuse stale state instead
of reseeding cleanly.

fix(web): show an explicit empty state for Shared folders

MirrorLinksSection rendered its heading and intro paragraph whenever
any environment advertised mirroring, even when every environment had
zero links, leaving a bodyless heading — the common case. Track
per-environment content status and render a "No shared folders" row
once every environment has reported none, matching the empty-state
convention used elsewhere on this page (AccessRows).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g path

agentStagingPath interpolated directive.syncId directly into a file
path; since it's host-controlled input (untrusted, per this module's
own trust boundary) with no character restriction, a compromised host
could set it to a traversal sequence and make this origin-side agent
write or delete files outside stagingDir. Percent-encode it first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nks warning

className="text-warning" on SettingsRow had no visible effect since
the row's own children (h3, description p) set their color explicitly.
Wrap the error text in a span instead, matching the pattern used by
ConnectionsSettings' WSL failure row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	apps/server/src/environment/ServerEnvironment.ts
#	apps/server/src/http.ts
#	apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
#	apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
#	apps/server/src/persistence/Migrations.ts
#	apps/server/src/server.test.ts
#	apps/server/src/server.ts
#	apps/server/src/ws.ts
#	apps/web/src/components/Sidebar.tsx
#	apps/web/src/reactGrabBoundary.test.ts
The merge commit renumbered these upstream migrations to 045/046 to
avoid colliding with the fork's own 041-044 mirror migrations, but the
in-file test group labels were staged before the rename edit landed.
@github-actions github-actions Bot removed the 📱 Native Change Changes the native fingerprint; merging blocks production OTAs until a new store build ships. label Aug 30, 2026
@github-actions

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.3 KiB 13.3 KiB −8 B (−0.1%) 15.1 KiB ✅
Codex Thread snapshot wire 6.9 KiB 6.9 KiB 0 B (0.0%) 7.3 KiB ✅
Codex Live turn WebSocket wire 6.4 KiB 6.4 KiB −8 B (−0.1%) 7.8 KiB ✅
Codex Live turn WebSocket decoded 55.5 KiB 55.5 KiB 0 B (0.0%) 66.4 KiB ✅
Codex Live turn messages 9 9 0 (0.0%) 21 ✅
Claude Total thread wire 13.4 KiB 13.3 KiB −106 B (−0.8%) 15.1 KiB ✅
Claude Thread snapshot wire 6.9 KiB 6.9 KiB −15 B (−0.2%) 7.3 KiB ✅
Claude Live turn WebSocket wire 6.5 KiB 6.4 KiB −91 B (−1.4%) 7.8 KiB ✅
Claude Live turn WebSocket decoded 56.4 KiB 56.4 KiB −88 B (−0.2%) 66.4 KiB ✅
Claude Live turn messages 11 9 −2 (−18.2%) 21 ✅

Baseline: 2daff8c · PR result: a654233 · Source CI: failure

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@malekelkssas
malekelkssas merged commit 76b213a into main Sep 3, 2026
19 of 20 checks passed
@malekelkssas
malekelkssas deleted the feat/project-mirroring branch September 3, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants