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:
POST /projects/:id/files registers only path, byte size, and SHA-256 hash.
- The returned server
FileId selects the authenticated Yjs file room.
- The extension seeds that room once, then reads the authorized snapshot back
and verifies the exact identity, bytes, size, and hash.
- Only verified metadata is announced in the retained project manifest room.
- 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
- Start the normal local database, server, and relay, then open two Extension
Development Host windows signed in as different accepted project members.
- Open the same active project in both windows.
- 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.
- Open the file on both sides, edit from the peer, and confirm the creator editor
converges through the existing Yjs binding.
- Repeat with an empty file and a file in a newly created nested directory.
- Verify an observer cannot publish, excluded and oversized files are not
registered, and a quota refusal creates no manifest entry.
- Pre-create different bytes at the peer path and verify Tether preserves them,
shows the fixed conflict warning, and does not map that file.
- 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.