Repository navigation
feat(mirror): project files can stay on the client while agents run on the host - #1
Merged
Merged
Conversation
…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.
This reverts commit cc4aae9.
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.
…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
…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>
…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.
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 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.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
ProjectOriginon projects,mirror.*RPCs,mirror:syncscope,projectMirroringcapability,t3.jsonmirror.includeallowlist.GitSyncsnapshot/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.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.includeentries), 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) andpnpm vp test run src/orchestration/Layers/ProviderCommandReactor.test.ts(turn gate).