Skip to content
| Marketplace
Sign in
Visual Studio Code>Other>Tether: Live Collaborative CodingNew to Visual Studio Code? Get it now.
Tether: Live Collaborative Coding

Tether: Live Collaborative Coding

Tether

|
5 installs
| (1) | Free
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

Tether

The VS Code extension half of Tether.

Connecting to Tether

Install the extension and sign in. It always uses the hosted API https://api.teth3r.app/ and relay wss://relay.teth3r.app. These addresses are not VS Code settings and extension users cannot change them. Production extensions ignore endpoint environment variables inherited from their host.

For local development, set both values in a local, uncommitted debug launch configuration for the Extension Development Host:

"env": {
  "TETHER_API_URL": "http://localhost:3000",
  "TETHER_RELAY_URL": "ws://127.0.0.1:8080"
}

Restart the Extension Development Host after changing either value. API overrides accept https:// on any host and http:// only on localhost, 127.0.0.1, or [::1]; relay overrides accept wss:// on any host and ws:// only on those loopback hosts. An empty, invalid, or unsafe override uses the hosted default. Diagnostics name only the environment variable, never its value.

Running it

cd packages/tether
npm install

Then open team-31-synctax.code-workspace (File > Open Workspace from File) at the repository root and press F5 (or use the debug menu and click run). The Extension Development Host then opens with Tether loaded. The Tether icon appears in its activity bar.

Command Purpose
npm run compile One-shot build: out/ for the tests, dist/ for the bundle
npm run watch:tsc Type-checks and rebuilds out/ on save. F5 starts this for you
npm run watch:esbuild Rebuilds the dist/ bundle on save. F5 starts this too
npm run lint ESLint over src
npm test Compiles, lints, then runs the tests in a real VS Code instance
npm run package Builds a local, installable .vsix using the project-local @vscode/vsce dependency

After installing dependencies, run npm run package to create tethercollab-0.0.1.vsix in this folder. In VS Code, run Extensions: Install from VSIX… and select that file. The package includes the production bundle; no global vsce installation is required. Marketplace publication uses one manual Actions run on production with a version tag. See the release and credential setup guide. Do not publish locally or configure local Marketplace credentials.

Tests also run from the Testing view in the activity bar, which needs the watch task running. The Extension Test Runner it uses is already in this folder's recommended extensions, so VS Code offers to install it on first open.

After changing code, reload the Extension Development Host window (Cmd+R / Ctrl+R) rather than relaunching it. The full VS Code API surface is node_modules/@types/vscode/index.d.ts.

Adding a feature

A new view in the Tether sidebar. The container already exists, so you only add the view. In package.json, append to contributes.views.tether:

{
  "id": "tether.participants",
  "name": "Participants",
  "icon": "media/tether.svg",
  "when": "tether.authState == ready"
}

icon is technically optional (as VS Code only shows it if the view is dragged out of the Tether container), but the manifest linter warns when it is missing.

Feature Instructions
Content in a view ProjectsProvider backs both project views and branches on its scope (owned or shared). Its root children are projects and each project expands to members. Call refresh() to reload a root list or invalidate(projectId) to reload one project's members.
A command Contribute it under contributes.commands in package.json, then register the handler with vscode.commands.registerCommand in activate and push the disposable onto context.subscriptions.
A setting Add it under contributes.configuration.
A call to the relay The request and response types are already written. Import them from the shared package

The shared contract

packages/shared holds the REST and WebSocket contracts the extension and the relay both work from.

import { AuthProvider } from '@tether/shared/rest';
import { WsCloseCode } from '@tether/shared/ws';

TetherAuthProvider is the extension's only authentication provider. GitHub or Microsoft verifies the user's identity during browser OAuth, but the resulting provider credential is exchanged by the backend and never becomes the Tether session used by extension features. The access token stays in provider memory; the refresh token stays in VS Code SecretStorage.

It is a symlinked local package, which vsce cannot walk, so esbuild.js inlines it into dist/extension.js and the .vsix ships that bundle with no node_modules. tsc still builds out/ for the tests and for type errors.

Tether sign-in

src/auth.ts coordinates sidebar visibility with the registered Tether provider. On activation it silently restores only provider ID tether with empty scopes. It stores the session ID needed for sign-out, not either token. Provider events re-check the gate so expiry, revocation, or account replacement updates the sidebar without a window reload. After finding a session, the coordinator calls authenticated GET /auth/me and opens project features only when the returned user ID matches the VS Code session and the account has a username.

The coordinator reports tether.authState with these values:

Value Meaning
unknown The first probe has not finished. Only ever true for a moment on startup.
noSession The provider has no usable Tether session.
signingIn One interactive OAuth attempt is running; duplicate command calls reuse it.
checking A session exists, but /auth/me has not yet confirmed its identity and onboarding state.
usernameRequired The session is valid, but the account has no username. Project features stay closed.
ready The session identity matches /auth/me and username onboarding is complete.
signingOut Local removal is running and authenticated views are already hidden.
error Session restoration or readiness verification failed; the welcome view offers a retry.

To gate a feature, add one when clause:

"when": "tether.authState == ready"

Commands take the same clause under enablement.

tether.welcome is the view shown while the gate is closed. Its viewsWelcome entries must cover every non-ready value in AUTH_STATES exactly once, or the panel renders blank; a test enforces this. Entries for one view concatenate, so their when clauses must be mutually exclusive. A usernameRequired account is prompted for a username via tether.claimUsername (triggered once on arrival at that state, and reachable again from the welcome view or Command Palette); a successful claim adopts the resulting session and refreshes readiness, moving the account to ready.

The create-project flow also enforces the gate inside its command handler. It captures the initiating user ID, verifies that same ready user before preflight and again before creation, and asks the API layer for a token belonging to that user. An account replacement therefore cannot move an in-progress creation to the new account.

AuthService publishes each transition as one atomic snapshot. readyUserId is present only in ready and is the sole authorization signal for starting a protected project operation. continuityUserId is separate: after an account has passed readiness, it allows existing file mappings to survive a check or a transient session or /auth/me transport failure for that same session. The provider's observed session ID alone never establishes continuity. Sign-out, session removal, account replacement, identity mismatch, and username loss all clear continuity synchronously and require readiness to pass again.

To test the signed-out path, run Tether: Sign Out. Local credentials are cleared before backend revocation, so a network failure cannot leave the extension locally signed in. The provider event hides project views without a reload. Run Tether: Sign In to choose GitHub or Microsoft and start a new browser OAuth attempt.

Everything disposable goes on context.subscriptions. Reloading the Extension Development Host window re-runs activate. Anything not registered for disposal leaks one copy per reload.

Members

The My projects and Shared with me views use the same ProjectsProvider. Each project row expands to a membership list loaded lazily by MembersSource and cached per project until invalidated. Every signed-in viewer, including an observer, can expand the tree. Rows preserve API order (host first) and render the member's plain-text display name with @username · role; they do not expose email addresses or interpret display names as Markdown or codicons.

Context menus are gated per row rather than by a global viewer-role key, because the viewer can hold a different role in each project:

Row contextValue Available inline actions
Project hosted by the viewer project-host Invite People in My projects
Project shared with the viewer project Open Shared Project in Shared with me
Host membership member:host None; the owner cannot be managed
Member or observer managed by the viewer member:member:manageable or member:observer:manageable Change Member Role, Remove Member
Member or observer the viewer cannot manage member:member or member:observer None

Leave Project… and Delete Project… are labelled context-menu actions in a separate final group, not inline icons. Right-click the relevant project, or focus its row and press Shift+F10, to find them. Leaving still requires confirmation; deletion still requires the exact project name. Cancelling either prompt changes neither membership nor project state. Delete remains host-only in My projects; Leave remains non-host-only in Shared with me.

tether.members.changeRole offers Member and Observer and invalidates that project's member cache after success. tether.members.remove and tether.leaveProject require modal confirmation; leaving refreshes the shared-project list. Those item-only commands are hidden from the Command Palette, while tether.openSharedProject remains available there without a row and presents its own project picker. Their handlers repeat the permission checks as a defensive UX check. The server's host-role check remains the authorization boundary for member mutations. A live role change invalidates the affected member cache and refreshes both project lists; membership-related terminal room closes refresh both root lists. Neither path requires an extension restart.

Pending invitations

The Pending Invites view lists only the signed-in user's unexpired pending invitations. Accept Invitation atomically settles the invitation, creates its stored membership, and refreshes both the pending and Shared with me views; Decline Invitation creates no membership and refreshes the pending view. If another window settles the same invitation first, Tether refreshes the stale row before showing the safe conflict message. The view-title refresh action retries transient load failures without reloading the extension.

For the two-account editable smoke test, sign in to separate Extension Development Host profiles. From the host account, use Invite People on a hosted project, enter the second account's Tether username, and select Member. In the invited account, accept the row under Pending Invites, confirm the project appears under Shared with me, then choose Open Shared Project on that row. Select and confirm its local root. Tether opens the first confirmed text file; editing it in either host should update the other without entering a project ID, room name, or relay URL. Repeat with Observer to verify that the invited editor opens read-only while still receiving host edits.

Observer editors are session-read-only

Tether uses VS Code's workbench.action.files.setActiveEditorReadonlyInSession command for observer documents and workbench.action.files.resetActiveEditorReadonlyInSession when the viewer is promoted. Inspection against the supported VS Code host confirmed that its session override is keyed by resource URI, so it survives editor switches. Reset removes that URI's session override rather than forcing it writable, so underlying configuration and filesystem read-only rules still apply. VS Code exposes no owner identity for this single URI override, however, so reset also removes an override the user manually set before Tether touched the file. Promotion requests for inactive documents are queued until their editor becomes active, and repeated role events do not repeat a command that already represents the desired state.

The built-in commands cannot target an inactive editor or accept a resource URI. On document release, Tether resets an active editor; for an inactive editor it immediately discards its own bookkeeping and writes an Extension Host diagnostic that VS Code may retain the URI's override until the window session ends. This avoids an unbounded retained-document map without visibly reopening or focusing a document the user closed. If needed, reopen the file and run workbench.action.files.resetActiveEditorReadonlyInSession manually. Promotion is different from release: its reset remains queued because the document is still tracked.

The alternatives were less safe: files.readonlyInclude persists and would require merging user-owned settings, while reverting local edits from the Y.Doc introduces a visible edit/revert race and risks broadcasting an edit the relay must reject. Successful owned and shared project-list responses replace separate snapshots in a shared viewer-role store, so projects omitted by a later response are evicted without clearing the other scope; all REST snapshots are cleared when the authentication session changes. After a live membership.roleChanged frame arrives, the registry's role takes precedence only while that project still has a connected room. DocumentRoomTracker applies only real read-only state transitions to bound file documents and best-effort releases adapter state when tracking ends.

Production activation uses ProjectFileRoomResolver: after a created project's initial room seed completes, its durable local root and server-issued file IDs allow DocumentRoomTracker to bind matching open text documents. Observer files resolved through that mapping receive the same session-read-only enforcement described above; unresolved, binary, pending, or ambiguously mapped documents remain untouched.

Collaborative connections

All Hocuspocus connections go through src/collab/room-provider-registry.ts:

const lease = await registry.acquire(fileRoom(projectId, fileId));
if (!lease) { return; } // signed out; onDidChangeSession fires once a session exists
lease.document;   // Y.Doc — read the FileDoc key you need; the registry reads none
lease.awareness;  // for cursors and presence
const sync = await lease.synced;  // always settles: { outcome: 'synced' } | { outcome: 'rejected', reason } | { outcome: 'disposed' }
lease.dispose();  // idempotent; the last dispose destroys the connection
registry.onDidChangeSession(() => { /* old leases are dead; acquire again */ });

One connection per room name; re-acquiring joins it. No session, no connection: acquire yields undefined rather than opening a socket. A connection belongs to the session (Tether user id) it authenticated under: its token callback supplies only that session's token, and acquire under a different session drops it before connecting afresh — so an account switch can never inherit a connection, even before the session-change event arrives. The registry listens to TetherAuthProvider.onDidChangeSessions: a removed session's connections are destroyed at once — before any read, so signing back in as the same user cannot keep the old socket — their synced settles as disposed, their leases go inert, and onDidChangeSession fires; an added session is always announced; a changed one (token refresh) changes nothing unless the account moved.

Membership closes and role changes

The shared shouldRetry policy decides whether a closed socket reconnects. Retryable closes leave the registry entry intact while the registry schedules the reconnect. Every other close is terminal: the registry destroys the provider, settles a pending initial sync as disposed, removes the room, and fires onDidCloseRoom once. DocumentRoomTracker then releases that room's editor binding and lease without immediately re-acquiring it. Resolver refreshes preserve suppression for that document-room pair but may remap the document to a different room; explicitly opening the document or changing the Tether session permits a fresh acquisition of the closed room. Opening the document again creates a fresh room and Y.Doc, so an edit refused with READ_ONLY_VIOLATION cannot be replayed.

Reconnect schedule

The registry disables Hocuspocus's flat post-close reconnect and owns one timer per room. Consecutive retryable closes use the shared exponential full-jitter policy, starting at 500 ms and capped at 30 seconds; a successful sync resets the attempt counter. Releasing the last lease, changing sessions, a terminal close, or disposing the registry cancels any pending timer. onDidChangeConnectionState publishes the aggregate connecting, connected, reconnecting, or offline state for each project to the status bar. Hocuspocus's internal forced-4408 path bypasses the registry close callback; its transport status still marks the project as reconnecting until a fresh document sync completes. Normal awareness and heartbeat traffic prevents that silence detector from firing during a healthy connection.

Handshake refusals arrive separately through Hocuspocus's onAuthenticationFailed. Numeric reasons apply the same close-code policy, while non-numeric provider failures are treated as transient. Retryable refusals remain pending and use the registry schedule; a terminal refusal disables reconnection, drops the registry entry, releases the editor binding, and emits the same onDidCloseRoom event and notice as the matching close frame. TOKEN_EXPIRED refreshes the session before its one allowed retry; a second consecutive refusal drops the room.

Close Registry behavior Message
TOKEN_EXPIRED (4002) Retry; keep the room None
NOT_A_MEMBER (4003) Drop the room You are no longer a member of this project
MEMBERSHIP_REVOKED (4004) Drop the room; use the preceding revocation frame's reason when available The host removed you from this project, with the reason when provided
READ_ONLY_VIOLATION (4005) Drop the room and discard the refused edit with it You have view-only access, so that edit was not applied
QUOTA_EXCEEDED (4008) Drop the room; explicit reopen creates a fresh document That edit was not applied; delete content to free space, copy unsent work, then close and reopen the file
SESSION_ENDED (4009) Drop the room The host ended this session
SERVER_SHUTDOWN (4010) Retry; keep the room None
LEFT_PROJECT (4011) Drop the room You left this project
Hocuspocus RESET_CONNECTION (4205) Retry with shared backoff; keep the room None
Hocuspocus CONNECTION_TIMEOUT (4408) Retry with shared backoff; keep the room None
Non-numeric authentication refusal Retry with shared backoff; keep initial sync pending None
Transport closes below 4000 Retry; keep the room None

Notifications are plain text and deduplicated by project and close code, so several open file rooms do not produce repeated messages. A fresh provider connection clears membership-notice suppression. Quota suppression spans newly opened rooms: a blocking quota.warning frame and its 4008 close share one notice, regardless of arrival order. A non-blocking project measurement below 85%, or a session change, re-arms quota notices. A file-cap frame identifies the file limit in the message without replacing the project's storage percentage. Other non-retryable close codes still drop the room and emit onDidCloseRoom, but do not have a membership notification.

Status bar: connection and storage (#191)

ConnectionStatusBar owns one left-aligned item. It follows the active document's project through FileRoomResolver, falls back to any project with a live room, and hides when neither exists. Signing out leaves a generic offline indicator for the current document while clearing quota details; changing accounts does not retain the previous account's selection.

State Status text Tooltip
Initial connection $(sync~spin) Tether: connecting… Connecting to Tether
Synchronized $(check) Tether Connected to Tether
Retry pending $(sync~spin) Tether: reconnecting… Attempt number and seconds until the next retry
Offline $(debug-disconnect) Tether: offline Reopen the document, or sign in when signed out

The registry validates quota.warning measurements as finite, non-negative byte counts with a boolean blocking flag and derives the project from the receiving room. quotaFor(projectId) exposes the last project measurement and onDidChangeQuota announces validated frames. At 85% or above, the item appends $(warning) N % storage; a measurement below 85% clears it. The tooltip calls this last reported storage because the relay sends snapshots on connection and threshold crossings, not on every edit. File-cap refusals only affect the notice. This UI and quota decoding do not inspect any FileDoc.* content.

After a quota refusal, DocumentRoomTracker releases the editor binding and suppresses automatic reacquisition. Copy any unsent local work, close the file, then reopen it to synchronize a fresh Y.Doc with the accepted relay state. Free space with a deletion before retrying an insertion. The refused edit is never replayed by the old room. Other connected users and files can continue working.

For a manual check, open files from two projects and switch editors to verify the selection; interrupt relay access to observe the retry countdown, then restore it and confirm the check mark returns. Cross 85% storage, delete below it, and reconnect a client that missed the deletion to verify warning recovery. Attempt an over-limit edit to confirm one notice, no automatic retry, and fresh state after explicitly closing and reopening. Repeat sign-out/sign-in to verify quota does not carry over between accounts.

The registry exposes membership lifecycle events, including a fresh-room signal used to bound notification deduplication:

registry.onDidCloseRoom(({ room, projectId, code, reason }) => {
	// The room has already been dropped; release anything that depended on its lease.
});
registry.onDidOpenRoom(({ room, projectId }) => {
	// A fresh provider is now stored; later closes belong to a new notice incident.
});
registry.onDidChangeRole(({ room, projectId, role }) => {
	// A valid membership.roleChanged frame was received for this active room.
});

const currentRole = registry.roleFor(projectId);

roleFor(projectId) returns the last role announced for that project, or undefined until a role-change frame arrives. When local awareness already contains a user, the registry republishes that object with the new role while preserving its other fields. It does not create awareness state; publishing presence initially remains #80.

src/collab/document-room-tracker.ts acquires a document's file room on open (and at activation for restored editors — hence onStartupFinished), binds the editor bidirectionally to FileDoc.CONTENT after initial sync, and releases both binding and lease on close. Local changes become Y.Text operations; remote changes update only the changed editor range, while edits made before initial sync are queued safely. On onDidChangeSession it drops every lease and retries every open document, so a file opened while signed out connects once signed in. Which documents count is the FileRoomResolver's call.

Production document resolution is handled by src/collab/project-file-room-resolver.ts. The unchanged tether.activeProjects.v1 workspace-state key contains a version-1 envelope of strictly decoded records. Host records contain five fields: activation state, Tether userId, server projectId, server-confirmed projectName, and serialized local rootUri. Joined records additionally contain source: "joined" and a hydratedFiles list of server IDs and paths. These fields keep downloaded roots out of host seeding recovery and restrict resolution to files actually materialized. Provisional three-field records are intentionally ignored because they cannot prove that initial room seeding completed. Once a user has passed the ready auth state, the resolver refreshes each active project's server-authoritative file metadata through GET /projects/:id/files; pending records and another user's dormant associations never enter the active cache. Only confirmed text nodes map to rooms, using their server-issued FileId rather than a path or locally derived identifier.

Resolver snapshots publish atomically, without exposing a transient empty mapping during same-user refreshes. Each refresh merges by exact project/root association: only a typed network interruption may retain that project's previous in-memory mapping. Authentication failures, server rejections, invalid responses, and unknown failures remove that project's cached mapping; successful projects still update independently, and a successful empty file list removes the project's previous files. A first load failure has no fallback. On each published change, DocumentRoomTracker reconciles open documents individually: unchanged rooms retain their current binding, lease, or pending acquisition; removed and remapped rooms alone are released; and newly mapped editors connect.

A same-session readiness check or transient authentication transport failure preserves existing mappings without authorizing new work. Sign-out, session removal, account replacement, identity mismatch, username loss, and non-network readiness failures clear them synchronously. Persisting a project move or assigning its root to another project removes the displaced mapping immediately after the durable write; the new mapping is not published until its own metadata refresh succeeds. Revalidating exact durable associations before publication also prevents an older in-flight refresh from restoring a displaced mapping.

Project creation first stages a pending user/project/name/root record, then runs idempotent initial room seeding through the interactive retry flow. Only the exact association whose seeding completes is atomically promoted to active before its file mappings are published and its manifest lease is retained. If the host declines after an incomplete interactive attempt, that exact pending association is discarded and the local folder remains inactive. An interruption or unexpected failure leaves the pending association recoverable. After reload, a truly ready session restores active manifest leases and makes at most one silent seed attempt per exact pending host association during that Extension Host lifetime, without opening progress UI. Pending joined roots are retained for an explicit retry and are never sent to the host seeder.

Consumer Plugs in at
#87 editor binding Implemented by DocumentRoomTracker through bindEditorToYText
#80–#82 presence lease.awareness
#45 reconnection, quota Registry reconnect scheduling and the connection/storage status bar (#191)

Developer endpoint overrides: TETHER_API_URL defaults to https://api.teth3r.app/ and TETHER_RELAY_URL defaults to wss://relay.teth3r.app. They are read once from the Extension Host environment at activation; the same secure/loopback-only policy in src/config.ts rejects unsafe values and uses the hosted defaults.

Tests cover provider lifecycle with an injected factory, editor binding and tracker behavior inside a real VS Code test host, and CRDT convergence independently of the UI. The tracker suite's resolver recognises only documents the current test opened, because the test VS Code restores editors from earlier runs. The real two-window demonstration remains #88 and depends on the production project/file resolver from #176.

Opening and restoring an owned project (#227)

Click a project name in My projects, or its Open Project action, to open the saved local folder in a new VS Code window. The disclosure arrow still opens the member list. Choose Another Project Folder… in the row's context menu lets the host choose a different location even while the original folder exists.

Tether checks the current account's ownership before opening. If the saved folder is missing, inaccessible, or unknown in this workspace, it offers a local folder picker and an explicit Restore Project confirmation. Recovery downloads the existing project's authorized text snapshots; it does not create another server project or upload local contents. Conflicting local files are preserved. Excluded and binary files are outside text-snapshot recovery. Downloaded host roots use the same source: "joined" marker as other downloaded roots so background host seeding cannot upload them; this marker does not change the host's server role.

The new window has its own workspace state. ProjectFolderOpener transfers only the confirmed account/project/root metadata through an expiring, local global-state record, then rechecks ownership before establishing the new window's association. The transfer is bound to that user and exact folder, expires after ten minutes, and is removed after successful activation. It never contains tokens or document contents. The originating workspace remains open with its local files intact.

If the server reports that a project is gone, Tether clears its stale local association and explains that a deleted hosted copy cannot be restored. Use Create Project to share a local folder as a fresh project. Creation now offers the folder picker in an empty VS Code window as well as Choose another folder… in an existing workspace. Normal scan, preflight, confirmation, and account checks still apply. Cancellation or a failed pre-activation recovery leaves the previous association intact; any safe partial download remains on disk for an explicit retry.

See verification and manual steps.

Opening an accepted shared project (#46, #199, #251)

OpenSharedProjectFlow owns the native UI around JoinedProjectActivation, the download/activation half of the Shared with me flow. A member or observer can start it from a project's inline Open Shared Project action. Tether: Open Shared Project is also available in the Command Palette; that entry loads the current accepted shared projects and preserves each server project ID, so duplicate display names remain unambiguous. Both entry points require a fully ready Tether account. The command handler rejects forged rows and hosted projects instead of falling through to the picker.

The flow captures the ready account before its first UI await. A new association prompts for a local folder; an already active joined project reuses its durable, account-scoped root only after offering Reopen Project or Choose Another Folder. Choosing another destination returns to the folder picker and requires a second confirmation, so a missing, unsafe, or unwanted saved root cannot trap future opens. Either path shows a modal confirmation naming the project and destination before staging a root or writing files. After confirmation, cancellable notification progress calls the internal activation command (not itself a Command Palette entry). A successful activation returns the confirmed joined registration alongside the first-file target. If the selected root is already the sole current local workspace, the flow reveals that file in place. Otherwise it passes both to ProjectFolderOpener, which persists the association before opening the root in a new window:

const result = await vscode.commands.executeCommand<JoinedProjectResult>(
    ACTIVATE_JOINED_PROJECT_COMMAND, projectId, selectedRootUri, expectedUserId,
    cancellationToken,
);
if (result?.outcome === 'started') {
    await folderOpener.open(result.registration, sessionCurrent, result.firstFile);
}

Import ACTIVATE_JOINED_PROJECT_COMMAND and JoinedProjectResult from src/projects/joined-project-activation.ts. Capture expectedUserId before awaiting picker input; never substitute the project owner's ID. The command checks readiness and the service guards identity changes, including signing out and back into the same account, throughout the operation. Cancelling a picker or confirmation is a normal, silent outcome. Cancelling progress aborts in-flight project/file HTTP reads and stops waiting for room synchronization. It is silent before materialization; once file creation has begun, the flow reports that a safe partial copy may remain for retry. Only local file: roots are supported.

The receiving window rechecks membership, republishes the viewer role, activates its workspace-scoped association, refreshes project metadata, and only then reveals the first file. The departing window never opens that file as a loose editor. A failed folder open leaves the hydrated files, active association, and expiring handoff available for Open Shared Project to retry; the flow reports the join as incomplete instead of claiming the project is open.

The service fetches the authorized project and every canonical file-list page, then downloads text snapshots. It decodes FileDoc.CONTENT from Yjs state and checks each snapshot's ID, path, UTF-8 size, and SHA-256. Directories and binary contents are not downloaded. Snapshots may contain newer text than file-list metadata; only their stable ID/path must still match that list.

All snapshots and destinations are checked before file creation. An identical local file is reused. Different contents, dirty editor buffers, overlapping project/account roots, unsafe paths, and symlinks beneath the selected root stop the operation. A complete temporary file is linked into an absent destination without replacing existing files; normal completion and failure clean up the operating-system temporary directory. Staging happens outside the watched project root, so it cannot be mistaken for a user-created file. Virtual and remote URI providers are refused. Filesystems without hard links use an exclusive copy instead.

Only after materialization and an authenticated manifest-room handshake does the root become active. The first text document is opened and connected through the existing tracker; other confirmed files connect when opened. A project with no text files still opens and reports that there is no document to reveal. The live room wins over the snapshot, including a host deleting all text before the initial sync. Observers receive the read-only role before mappings are published. Membership revocation removes the mapping, refreshes Shared with me, and invalidates pending work without deleting local files.

Failures return fixed reasons: cancelled, offline, membershipLost, sessionChanged, localConflict, unsafePath, invalidSnapshot, or unavailable. The UI maps them to fixed retry or recovery guidance; messages do not contain backend errors, credentials, content, or local paths. A partial new activation stays pending and is called out explicitly; retry reuses identical completed files. A failed reopen preserves its already-active root and reports that state instead of claiming the project was never activated. If shared content changed in the meantime and now conflicts, use another root rather than overwriting the earlier copy. Reopening an active root downloads only missing, previously hydrated files and reuses existing file leases. Added or renamed server paths are not silently mapped to unrelated local files; opening into a fresh root materializes the current list.

Run locally from the repository root:

npm --prefix packages/tether run compile
npm --prefix packages/tether run lint
npm --prefix packages/tether test
npm run test:shared
npm run test:relay
npm run test:server

The joined-project Extension Host tests use actual local files, Yjs documents, the production resolver and editor tracker, with fake authenticated API/room transports. They cover collisions, traversal/symlinks, partial failures/retry, account replacement, rebinding, revocation, observer opening, and snapshot-to-live handoff. They do not replace #200's real two-account deployed demonstration.

See joined workspace handoff verification for the current automated evidence and native two-account checklist.

Layout

media/tether.svg              activity bar icon, currentColor only
src/extension.ts              activate(): auth provider, sidebar views, room registry and tracker
src/config.ts                 hosted endpoint defaults and validated developer environment overrides
src/editor-binding.ts         bidirectional TextDocument and shared Y.Text synchronization
src/auth.ts                   Tether-session state coordinator for sidebar gating
src/auth/                     Tether authentication provider and session store
src/oauth/                    PKCE, attempt store, URI handler and the auth REST client
src/collab/                   room provider registry, document tracker, file-room resolver, session read-only editors
src/projects/                 project API client, project and member tree, host/leave actions, viewer-role store
src/test/                     integration tests, run inside a real VS Code
esbuild.js                    builds the bundle the extension host loads
dist/                         bundle, what the .vsix ships, gitignored
out/                          tsc output, what the tests run against, gitignored

Joined roots and local document paths are matched by their filesystem identity, so Explorer documents opened through a symlink alias reuse the same project and URI. Virtual documents such as Git diffs do not participate in disk conflicts. Windows-only filename restrictions apply only on Windows. Filesystems without hard links use an exclusive copy that refuses an existing destination.

A missing file snapshot leaves the association available for retry; it never creates an empty substitute. A missing project or lost membership removes the association. Reopening restores missing files from the original hydrated ID/path set, while preserving existing local files and dirty buffers.

The join regression suite also runs the production HTTP client against the real server router and bearer authentication, with fixture stores and relay responses. The server's PostgreSQL integration suite verifies the detail query, live-file totals, current roles, and access refusal against a migrated test database.

Live text-file creation (#230)

After a project is active, Tether watches each writable host/member root for new regular UTF-8 text files. It also consumes VS Code's workspace-wide create event, then coalesces duplicate notifications. Before any request, the extension waits for the file to settle. A dirty buffer is deferred until the same document is clean after a save or clean change; symlinks, binary or invalid UTF-8 data, mandatory exclusions, noncanonical paths, and files above 1 MiB are rejected.

Publication is deliberately ordered:

  1. POST /projects/:id/files registers only path, byte size, and SHA-256 hash.
  2. The returned server FileId selects the authenticated Yjs file room.
  3. The extension seeds that room once, then reads the authorized snapshot back and verifies the exact identity, bytes, size, and hash.
  4. Only verified metadata is announced in the retained project manifest room.
  5. The creator publishes its local file mapping.

Registration reserves a stable FileId as private pending metadata until the relay has verified and finalized the matching room. Pending rows are excluded from ordinary file listings and snapshots, but a writable client can recover one after a reload only when its local bytes exactly match the pending metadata. If bytes change before announcement, the creator rebases the still-pending metadata under that same FileId and replaces the provisional room before it can be announced. The recovery API exchanges paths, sizes, hashes, and opaque short-lived capabilities only; it never exchanges file text or Yjs state.

Connected peers treat the manifest as a notification, not as trusted content. They validate the entry, fetch the authorized snapshot, preserve any differing or dirty local file, materialize only an absent destination, persist joined-root hydration, and publish the mapping last. Observer peers consume and materialize announcements but never register local creates. Non-file: roots are not passed to the Node filesystem materializer.

Exact REST retries reuse the original file ID, and manifest writes are idempotent. Initial synchronization also reconciles an exact pending metadata row whose earlier publisher disconnected before announcement. Incoming work is serialized per root, bounded with other publications, retried for transient snapshot/materialization failures, and cancelled on account, membership, manifest, root, or extension-lifecycle changes. A local conflict is recorded as blocked before any mapping can be published; an in-memory block remains fail closed if workspace-state persistence or an older metadata refresh races it.

The feature is intentionally text-create-only. Rename, delete, binary transfer, .tetherignore, and broader manifest enforcement remain separate work. File contents stay in local files and Yjs rooms; REST, PostgreSQL metadata, logs, and user-facing errors receive no content.

Two-client acceptance check

  1. Start the normal local database, server, and relay, then open two Extension Development Host windows signed in as different accepted project members.
  2. Open the same active project in both windows.
  3. In the host root, create and save src/live-created.ts with visible text in one write. Confirm it appears in the peer root without reopening the project. If the create event observes an unsaved buffer, save that same document and confirm publication resumes without a retry prompt.
  4. Open the file on both sides, edit from the peer, and confirm the creator editor converges through the existing Yjs binding.
  5. Repeat with an empty file and a file in a newly created nested directory.
  6. Verify an observer cannot publish, excluded and oversized files are not registered, and a quota refusal creates no manifest entry.
  7. Pre-create different bytes at the peer path and verify Tether preserves them, shows the fixed conflict warning, and does not map that file.
  8. Reload or reconnect the creator and repeat the same create notification; confirm there is still one server file ID and one text manifest entry.

src/test/projects/live-file-sync-integration.test.ts reproduces this flow with two production services, real temporary roots, a shared in-memory manifest/file room, the production resolver/materializer, and the editor binding. The broader Extension Host suite retains create-project, join, resolver, snapshot, cancellation, permission, quota, and relay regressions.

Quota API client

ProjectApiClient.quota(projectId, expectedUserId?, options?) reads persisted project usage for later status-bar work. It validates safe integer counters, positive limits, the warning flag and the returned project ID. Supply the initiating account to reject responses after an account switch; options.signal cancels reads. No quota UI or write enforcement is included in this API addition.

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft