diff --git a/AGENTS.md b/AGENTS.md index 48a6374ab0af..e3b5771d797c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ The most common defect in this repo is a change that works on the path you teste - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. - **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. -- **Docs.** `docs/` splits by audience. Behavior changes that a user would notice belong in `docs/user/` (shipped-product voice, no repo tooling or source paths); architecture and contributor changes in `docs/internals/`; runbooks in `docs/operations/`; new vocabulary in `docs/internals/glossary.md`. +- **Docs.** Check whether the change makes existing guidance inaccurate. Apply the [documentation rules](#documentation) before adding anything. ## Dev servers @@ -120,11 +120,22 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - One concern per PR. If the description says "also", split it. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. +## Documentation + +Most code changes do not need an internal documentation change. Agents can read the code. + +- `docs/internals/` is for architectural decisions and their reasons, constraints that span components, and implementation traps that are hard to discover from the source. Before adding a paragraph, ask what a maintainer would get wrong without it. If reading the relevant code answers the question, leave it out. +- Do not document every feature, enumerate fields or methods, narrate control flow, maintain file catalogs, or append PR summaries. Types, tests, and code already record the implementation. The glossary defines shared vocabulary; it is not a feature index. +- Keep a local implementation explanation in a nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. +- When a documented decision or constraint changes, rewrite or remove the affected text. Do not append another account of the new behavior. A new internal page needs a distinct, durable reason to exist. +- `docs/user/` helps users accomplish tasks. Give each major feature a concise section explaining what it does, how to start, and anything unintuitive. A settings path is useful; descriptions of visible buttons, icons, layouts, animations, or every UI state are not. Before adding text, ask what task or decision it helps the user with. +- Keep user docs in the shipped product's voice, without implementation details or contributor tooling. Update the relevant feature section when how to use it changes. A UI tweak does not need a documentation entry, and a new control does not need its own page. +- `docs/operations/` holds maintainer setup, release, and debugging procedures. Keep instructions for operating an installed T3 Code server in the user guides. + ## Plans and work artifacts - Do not commit implementation plans, research notes, or agent scratch files. Keep temporary working material outside the worktree. `.plans/` is gitignored only as a safety net for legacy tooling. - Track active maintainer work in the GitHub issue or project item that owns it. External proposals follow `CONTRIBUTING.md` and belong in Ideas discussions. -- Put durable architecture, constraints, and decisions in `docs/internals/`. Update those docs when the product changes so agents find current facts instead of abandoned intentions. - A merged PR is the implementation record. Close or update its tracking item when the work lands; do not preserve a second checklist in the repository. ## How it works diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cd75ef96edb..caa8f8309bb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Developer Setup -See the [maintainer scripts guide](docs/internals/scripts.md#first-checkout) for the initial checkout, +See the [development runbook](docs/operations/development.md#first-checkout) for the initial checkout, development commands, tests, and platform-specific desktop packaging prerequisites. ## Read This First @@ -49,6 +49,10 @@ Explain exactly what changed. Explain exactly why the change should exist. +Follow the [documentation rules](AGENTS.md#documentation). Keep internal docs for decisions and +hard-to-discover constraints. Update user guides when how to use a feature changes; skip descriptions +of obvious controls and cosmetic changes. + Do not mix unrelated fixes together. If the PR makes anything resembling a UI change, include clear before/after images. diff --git a/apps/mobile/README.md b/apps/mobile/README.md index ed95b060fe3a..a9a8177c4ece 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -38,12 +38,8 @@ vp run dev:client:reset Run that reset once after installing or changing the Uniwind dependency patch. Cached transforms can otherwise reference its previous pnpm package path. Ordinary Metro starts still keep the cache. -Component edits use Fast Refresh. Connection-runtime edits replace the active Effect layer through -a stable atom runtime, preserving navigation and existing atom subscribers. Replaced registries -and managed runtimes dispose their resources; the app does not force a JavaScript reload. The Uniwind patch -skips global style invalidation when generated styles and themes are unchanged, while real style -changes still refresh. See [mobile development lifecycle](../../docs/internals/mobile-development.md) -for the lifetime boundaries. +Component edits use Fast Refresh. See [mobile development lifecycle](../../docs/internals/mobile-development.md) +before changing runtime ownership or refresh behavior. Build and run the local iOS dev client: @@ -51,6 +47,10 @@ Build and run the local iOS dev client: vp run ios:dev ``` +After changing a native dependency patch, rerun CocoaPods before rebuilding an existing iOS +project. pnpm gives each patch hash a new package path; Pods can otherwise keep compiling the +previous directory. + If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the reduced-capability local build. Personal Team builds omit the widget and share extensions, push entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged. diff --git a/docs/README.md b/docs/README.md index ce2187ee865f..fe159931b656 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,27 +23,34 @@ Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) ## Working on T3 Code -Everything below is for maintainers. Setup lives in the [root README](../README.md); -policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../AGENTS.md). +Start with the [development runbook](./operations/development.md) and +[contribution policy](../CONTRIBUTING.md). + +Internal notes preserve architectural decisions, constraints, and implementation traps that the +source alone does not explain. Most code changes do not need an internal documentation update. Follow the +[documentation rules](../AGENTS.md#documentation) before adding one. - [Architecture overview](./internals/overview.md) -- [Workspace layout](./internals/workspace-layout.md) - [Glossary](./internals/glossary.md) -- [Scripts](./internals/scripts.md) - [Connection runtime](./internals/connection-runtime.md) -- [Voice input](./internals/voice-input.md) - [Providers](./internals/providers.md) +- [Model classification](./internals/model-manifest.md) - [Remote environments](./internals/remote.md) - [Server updates](./internals/server-updates.md) - [Resource telemetry](./internals/resource-telemetry.md) - [Product analytics](./internals/product-analytics.md) - [Environment auth](./internals/environment-auth.md) - [T3 Connect](./internals/t3-connect.md) -- [CI gates](./internals/ci.md) -- [Engineering work artifacts](./internals/work-artifacts.md) +- [Assistant citations](./internals/assistant-citations.md) +- [Mobile navigation](./internals/mobile-navigation.md) +- [Mobile development lifecycle](./internals/mobile-development.md) +- [Terminal runtime](./internals/terminal-runtime.md) +- [Voice input](./internals/voice-input.md) ### Runbooks +- [Development and local builds](./operations/development.md) +- [T3 Connect setup](./operations/connect-setup.md) - [Release](./operations/release.md) - [Observability](./operations/observability.md) - [Relay observability](./operations/relay-observability.md) diff --git a/docs/architecture/terminal-renderers.md b/docs/architecture/terminal-renderers.md deleted file mode 100644 index 4b66a13f68c4..000000000000 --- a/docs/architecture/terminal-renderers.md +++ /dev/null @@ -1,42 +0,0 @@ -# Terminal renderers - -Terminal sessions remain server-owned PTYs. Clients receive the existing raw byte stream and send -input and resize events over the existing terminal contracts; renderer choices never cross the -wire. - -## Ghostty alignment - -Android and web use the official `libghostty-vt` C ABI for parsing, terminal state, grapheme -boundaries, keyboard encoding, selection, and scrollback: - -- Android links the native shared library and converts render state into a compact JNI snapshot. -- Web loads a separately cached WebAssembly build and reads render state into a Canvas 2D surface. -- Both artifacts are built from the revision in - `native/libghostty-vt/VERSION`. - -The platform adapters deliberately own only platform behavior. Android owns its Kotlin Canvas and -touch integration. Web owns browser font shaping, the hidden IME textarea, clipboard and DOM input, -and its Canvas renderer. The web adapter also delegates application mouse encoding, word and line -selection, and OSC 8 hyperlink metadata to the official ABI. Browser conventions remain available: -holding Shift bypasses application mouse capture, and the platform link modifier opens hyperlinks. -React does not participate in terminal frames. - -The web runtime is singleton-scoped per browser tab so split terminals share one compiled module -and memory. Each visible terminal owns and frees its own terminal, render state, row iterator, cell -iterator, key and mouse encoder, and input event handles. Restoring captured scrollback temporarily -detaches the PTY callback so historical device queries cannot emit replies into the current shell. - -## Updating Ghostty - -Update and rebuild Android first, because mobile's `VERSION` file is the single source of truth for -the upstream pin (the upstream `LICENSE` lives beside it). Then run: - -```sh -pnpm --dir apps/web build:ghostty-wasm -``` - -Commit the regenerated web `wasm` artifacts. The build embeds the pinned revision into the binary as -semver build metadata, and the focused web ABI test reads it back through `ghostty_build_info` and -compares it against mobile's `VERSION` — so the web vendor directory holds only the artifacts, drift -cannot hide, and there is no second pin to keep in sync. The same test enforces the artifact budget -and exercises repeated create/write/free cycles with multi-codepoint graphemes. diff --git a/docs/internals/assistant-citations.md b/docs/internals/assistant-citations.md index 77bd53dff8d9..2c49163fadb4 100644 --- a/docs/internals/assistant-citations.md +++ b/docs/internals/assistant-citations.md @@ -1,135 +1,29 @@ # Assistant citations -> For maintainers. Using T3 Code? See [Quote an assistant response](../user/composer.md#quote-an-assistant-response). - -Assistant citations store a selected excerpt and its source in ordinary message text. They need -no database migration, citation table, or sidecar draft metadata. The saved excerpt remains -available even when the source response is missing or has changed. - -## Serialized form - -[`AssistantCitation`][contract] defines version 1. [`assistantCitations.ts`][shared] serializes it -as a Markdown link with the literal label `Assistant quote`: - -```text -[Assistant quote](t3-citation://v1///?text=&start=&end=&prefix=&suffix=&comment=) -``` - -The formatter percent-encodes each path segment and uses `URLSearchParams` for the query. The -environment, thread, and message IDs identify the source without a browser origin, so local, -remote, and tunnel clients share one form. Opening the source still requires access to its -environment. - -The parser accepts only version 1 with exactly three source IDs and one of each selector field; -`comment` is optional and links without one keep their original format. It rejects credentials, -ports, malformed encoding, and extra or duplicate query fields. IDs have a 512-character limit, -the quote and comment each fit in 8,000 UTF-16 code units, offsets are nonnegative safe integers -with `end > start`, prefix and suffix each fit in 32 code units, and the URI itself is capped at -160,000 characters. Malformed or unsupported links remain ordinary prompt text everywhere: -collectors, provider expansion, and readable fallbacks all skip them. Parsing validates the -payload, not the existence of its source. - -## Selection and source matching - -[`assistantTextSelection.ts`][selection] captures one native DOM range inside one assistant -response, including backward and paragraph selections. Empty range endpoints at neighboring blocks -are narrowed to the first and last selected text nodes before validating the source, so -triple-click selections that end at the next block still resolve. The text stream follows DOM -order, inserts line breaks between blocks and at `
`, and includes displayed code and link -text. Controls, hidden subtrees, CSS-generated content, and soft wrapping do not contribute. - -`text` keeps the exact selected excerpt, including line breaks and indentation. `start`, `end`, -`prefix`, and `suffix` use the rendered-text stream after each JavaScript whitespace run collapses -to one space, without trimming, in UTF-16 code units. Context boundaries avoid splitting -surrogate pairs. - -Resolution uses the same normalization and case-sensitive matching. It checks the saved offsets -against the quote and context, then searches for a context-matching occurrence if positions have -moved. A unique quote can still match after its context changes. Multiple context-matching -occurrences are ambiguous, even if one remains at the saved offsets; the resolver does not guess. - -## Composer and clients - -[`AssistantSelectionToolbar`][toolbar] offers **Cite in composer** for a selection within one -response and disables it above the quote limit. It shares [`selectionActions.ts`][actions] with -the terminal's selection menu: the observer opens actions only after a primary-button release from -a gesture that started inside the surface, waits out the 500 ms repeat-click window so -double/triple clicks can finish, anchors to the release point (or the text end for keyboard -selections), and dismisses on outside interaction, scrolling, or `Escape`. The toolbar registers -its own element with the observer so interacting with it cannot replace the captured quote. - -`ChatView` inserts the serialized link at the last composer cursor. A one-shot editor request then -opens the comment editor beside the cited source text, carrying the live DOM range for positioning -and an editing highlight; that range is transient and never becomes part of the citation. -`withAssistantCitationComment` trims outer whitespace and removes the field when blank; it never -changes the saved quote or selector. Restoring or pasting a citation does not reopen the editor. - -[`ComposerCitationNode`][node] is an inline Lexical `DecoratorNode` holding the quote and comment -as one atomic chip. Its label shows the comment when present, otherwise a short quote preview; the -node retains the full serialized source and returns it from `getTextContent()`. Comment edits -update the citation and serialized source together as one undoable change. The token parser, -clipboard handling, and cursor mapping treat citations as inline tokens alongside mentions and -skills, so drafts, stashes, and pastes reconstruct chips from the prompt string alone. Sent -messages keep the same links; `ChatMarkdown` renders valid citation URIs as read-only -[`AssistantCitationChip`][chip]s, and the whole chip supplies its serialized value to Markdown -copy so the displayed label is not duplicated as text. - -## Source navigation - -[`assistantCitationNavigation.ts`][navigation] routes to the source thread with -`#assistant-citation=`; the encoding keeps router hash -normalization from decoding quote whitespace or source IDs. Each click adds a fresh activation ID -to navigation state so the same link can reveal its source again after dismissal. - -[`useAssistantCitationTarget`][target] loads earlier history (bounded at 20 pages or a repeated -cursor, with a warning past that), expands a folded turn, and asks the virtual list to mount the -source row. While positioning, the timeline disables initial end-scroll and scroll maintenance and -keeps the cited row mounted until measured; the activation ID doubles as the list's data version -so pinning rows recalculates a stationary Legend list without discarding measurements. - -[`AssistantCitationSource`][source] resolves the DOM range, smoothly scrolls to it, and pulses -only that text through the CSS Highlight API: two 650 ms pulses driven by a registered opacity -property, with the second settling into a held highlight before fading out. Navigation owns the -highlight's start time and completion state: range repairs keep the original clock, remounting -resumes only the remaining time, and an expired or dismissed highlight cannot revive. Reduced -motion scrolls instantly and holds the highlight without pulsing. Browsers -without the Highlight API temporarily select the range and remove only that owned selection. -User navigation or `Escape` cancels pending positioning; missing messages or unmatched text -produce warnings without changing the saved quote. The separate comment-editing highlight uses -the same range-repair observer while its editor is open. - -Desktop shares the web implementation. Mobile's `ThreadFeed` uses `renderAssistantCitationsAsText` -to show sent quotes as readable Markdown blockquotes with each user comment below its quote; it -does not create citations or navigate to sources. - -## Provider input and titles - -[`ProviderService.sendTurn`][provider] calls `expandAssistantCitationsForProvider` before routing -to any adapter; persisted messages keep their links. Each distinct serialized link becomes an -inline `[assistant-quote-N]` marker, and one `` block carries JSON entries -with an `id` and the complete `citation`. The block identifies `citation.text` as quoted reference -material, not new instructions, and `citation.comment` as the user's comment about that quote. -JSON encoding escapes `<`, `>`, and `&` so content cannot close the wrapper. The expansion is -shared by all providers; there is no per-adapter citation handling or source lookup at send time. - -The 120,000-character provider input limit applies both before and after expansion: -[`composerSubmission.ts`][submission] checks the larger length client-side and keeps an over-limit -draft editable, and the server revalidates the expanded input before dispatch. Expansion does not -apply to pending user-input answers. - -`assistantCitationsToPlainText` supplies readable input for title seeds, stash previews, and the -server's title and branch-name helpers, which see the quoted text and comment rather than an -encoded URI. - -[contract]: ../../packages/contracts/src/assistantCitations.ts -[shared]: ../../packages/shared/src/assistantCitations.ts -[selection]: ../../apps/web/src/lib/assistantTextSelection.ts -[toolbar]: ../../apps/web/src/components/chat/AssistantSelectionToolbar.tsx -[actions]: ../../apps/web/src/lib/selectionActions.ts -[node]: ../../apps/web/src/components/ComposerCitationNode.tsx -[chip]: ../../apps/web/src/components/chat/AssistantCitationChip.tsx -[navigation]: ../../apps/web/src/lib/assistantCitationNavigation.ts -[target]: ../../apps/web/src/components/chat/useAssistantCitationTarget.ts -[source]: ../../apps/web/src/components/chat/AssistantCitationSource.tsx -[provider]: ../../apps/server/src/provider/Layers/ProviderService.ts -[submission]: ../../apps/web/src/components/chat/composerSubmission.ts +Citations carry the selected excerpt and source identity in ordinary message text. +Drafts, clipboard copies, stashes, and sent messages keep that representation +without a separate citation store. The saved quote remains usable when its source +disappears or changes. The [shared format](../../packages/shared/src/assistantCitations.ts) uses +stable environment IDs without a browser origin, so moving between local, remote, +and tunnel connections does not change a citation's identity. + +Source navigation is best effort. [Text selectors](../../apps/web/src/lib/assistantTextSelection.ts) +refer to rendered text after whitespace normalization, measured in UTF-16 units. +They cannot be applied to raw Markdown. The original excerpt keeps its whitespace; +normalization is only for locating it. Repeated text needs an unambiguous context +match, even if one occurrence still sits at the saved offsets. Guessing could +highlight the wrong claim. + +Navigation owns the highlight's lifetime across virtual-row remounts. The source +row must be fetched, unfolded, mounted, and measured before quote positioning +takes over from normal timeline scrolling. Remounting must not revive a dismissed +highlight. The [navigation hash](../../apps/web/src/lib/assistantCitationNavigation.ts) +uses base64url because router normalization otherwise decodes whitespace and +source IDs inside the citation URI. + +[ProviderService](../../apps/server/src/provider/Layers/ProviderService.ts) expands +citations before dispatch to any provider adapter. It sends the saved excerpt +without looking up the source, distinguishes quoted reference material from the +user's comment, and leaves persisted messages in their original form. Input +limits apply after expansion as well as before it. A draft that fits as encoded +links can exceed the provider limit once expanded. diff --git a/docs/internals/ci.md b/docs/internals/ci.md deleted file mode 100644 index 67851009f5c2..000000000000 --- a/docs/internals/ci.md +++ /dev/null @@ -1,44 +0,0 @@ -# CI quality gates - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -[`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) runs these quality gates on pull requests -and pushes to `main`: - -- **Check**: `vp check` (format and lint; this repo sets `typeCheck: false` in its lint options), - then `vpr typecheck` for the workspace type check. The same job - builds the desktop pipeline (`vp run build:desktop`) and verifies the preload bundle exists and - uses only imports that Electron's sandbox can load. The verifier parses imports, then executes the - trusted artifact with controlled bridge stubs to confirm that its required APIs are callable. -- **Test**: `vp run test` across the workspace. -- **Mobile Native Static Analysis**: `vp run lint:mobile` on macOS, wrapping - `scripts/mobile-native-static-check.ts`. A cheap Linux **Mobile Native Changes** job gates it: - the macOS runner only boots when the diff touches `apps/mobile` Swift/Kotlin sources, the - SwiftLint/detekt/ktlint configuration, the `Brewfile`, the check script, the root `package.json` - that defines `lint:mobile`, or `ci.yml`. Otherwise the job is skipped, which GitHub reports as - success for the required check. Renames are matched on both their old and new path. The gate fails - open in every other case: if the changed-file list cannot be resolved, GitHub truncates it, or the - gate job itself fails, the lint runs. -- **Release Smoke**: exercises release-only workflow steps through `scripts/release-smoke.ts`, so - release breakage surfaces on PRs rather than at tag time. - -[`.github/workflows/windows-tests.yml`](../../.github/workflows/windows-tests.yml) is a manual -Windows lane (`workflow_dispatch` only) on a Blacksmith Windows 2025 runner. The suite does not -pass on Windows yet, so it is not a required check; it exists so the work to get there can be -iterated against a real Windows box without one on hand. Dispatch it with `gh workflow run -windows-tests.yml --ref `, optionally with `-f package=` to run one workspace package -and `-f files=""` to run specific test files inside it. Once it is green, fold it into -`ci.yml`. - -`.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) -desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. It auto-enables -signing only when platform credentials are present. macOS passkey builds additionally require -`APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing. -Without the core signing credentials, it still releases unsigned artifacts. - -Preflight shares pnpm's lockfile verification results with the desktop build jobs through a small -artifact. This avoids repeating dependency checks, especially on Windows, without transferring the -large registry metadata cache. pnpm checks the current lockfile and policy before it reuses a result. -If the artifact is unavailable, installation runs the checks again. - -See [Release Checklist](../operations/release.md) for the full release/signing setup checklist. diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index e84d9ed4d703..21baaa4def06 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -1,211 +1,60 @@ -# Connection Runtime - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -The connection runtime is shared by web and mobile. It owns connectivity, -authentication, retries, transport lifetime, cached environment data, and -environment-scoped operations. - -Web and mobile mount this runtime once at the application root and compose it -identically: `apps/web/src/connection/runtime.ts` and -`apps/mobile/src/connection/runtime.ts` differ only in the platform layer they -supply. There is no legacy connection owner or supported mixed mode. - -## Composition - -[`connection/layer.ts`][layer] assembles the runtime: - -- `ConnectionResolver` ([resolver.ts][resolver]) resolves a catalog entry into a - prepared, authenticated endpoint for primary, bearer, relay, or SSH targets. -- `ConnectionDriver` ([driver.ts][driver]) prepares through the resolver, opens - one RPC session, and reports `preparing`, `opening`, and `synchronizing`. -- `RpcSessionFactory` ([rpc/session.ts][session]) performs one transport - attempt. It does not retry. `RpcSession` is the interface it returns, - exposing `client`, `initialConfig`, `ready`, `probe`, and `closed`. -- `EnvironmentRegistry` ([registry.ts][registry]) owns the catalog and the - per-environment scopes. -- `ConnectionOnboarding` and `RelayEnvironmentDiscovery` sit alongside the - registry. Startup calls `EnvironmentRegistry.start` and streams platform - registrations into `reconcilePlatform`. - -The registry creates one environment-scoped supervisor per environment. -`acquireSupervisor` serializes access per environment, reuses an existing -supervisor when the catalog entry is unchanged, and closes and recreates the -scope when it changed. `createServiceScope` builds an `EnvironmentSupervisor` -bound to a closeable scope and connects it; `run` and `runStream` execute caller -effects with that supervisor provided. - -`EnvironmentSupervisor` owns desired state, retry scheduling, and the active -session scope. React components do not create connections, transports, retry -loops, or RPC clients. - -## Connection State - -The supervisor is the only retry owner. - -1. A persisted or platform registration marks an environment as desired. -2. If the device is offline, the supervisor releases the active session and - waits for a signal without consuming retry attempts or running a timer. -3. When online, it asks the driver for one prepared connection and one RPC - session. -4. Transient failures retry forever with exponential backoff capped at 16 - seconds (`RETRY_DELAYS_MS`). A connection stable for 30 seconds resets - accumulated backoff. -5. Authentication or configuration failures remain blocked until an external - wakeup changes the relevant input. -6. An involuntary session close keeps the registration and cache, then retries. -7. Explicit removal closes the session and deletes the registration, - credentials, shell cache, and thread cache. - -### Wakeups - -Wakeup handling differs by phase, in [supervisor.ts][supervisor]: - -- During establishment, `waitForEstablishmentInterrupt` consumes and **ignores** - plain application activation. Restarting an in-flight attempt because the app - came to the foreground would only delay it. The exception is - `application-active-reconnect`, which mobile emits after a meaningful - background suspension; it interrupts establishment and resets the retry - ladder, because the OS may have silently killed the socket underneath the - attempt. -- Credential changes interrupt establishment only for relay targets, where a new - credential changes what is being established. -- Explicit disconnect, explicit retry, and going offline interrupt establishment - in every case. -- While waiting out backoff, application activation resets the retry ladder so a - foregrounded app reconnects immediately instead of serving the remaining - delay. -- Once connected, `monitorConnectedLease` handles plain activation by probing - the existing session (`lease.session.probe`, with a shorter timeout for - mobile's `application-active-probe`) rather than reconnecting; a healthy - session survives foregrounding. `application-active-reconnect` skips the probe - and replaces the lease outright. - -The UI derives `available`, `offline`, `connecting`, `reconnecting`, -`connected`, and `error` from supervisor state plus explicit data-sync state. -It does not infer connection health from cached data or the existence of a -transport object. An environment becomes `connected` after the socket opens and -the initial config RPC succeeds, proving that the server is responsive. Shell -and thread synchronization are independent data states. A healthy RPC transport -with a failed shell subscription is shown as connected with a synchronization -error, not as a reconnect that is not actually scheduled. - -## Data Boundary - -Finite requests, durable subscriptions, and commands are separate APIs: - -- Query atoms revalidate when the RPC generation changes. -- Subscription atoms switch to replacement sessions. -- Subscription failure handling in [rpc/client.ts][client] distinguishes two - cases. A transport failure (`isTransportFailure`: every failure is an RPC - client error) ends the inner subscription without resubscribing, so the outer - stream waits for the supervisor to supply a replacement session. A handled - domain failure runs `onExpectedFailure` and, when - `retryExpectedFailureAfter` is set, sleeps and resubscribes on the **same** - session. A healthy transport is never torn down for a domain failure. -- Mutations resolve the current environment runtime at execution time. -- Shell and thread snapshots are available while offline. -- Sync status is explicit and independent per domain. Shell status is `empty`, - `cached`, `synchronizing`, or `live`, with a separate `error` field; there is - no `failed` status. Thread status adds `deleted`. -- Cached shell and thread projections are never allowed to overwrite newer live - data during a fast reconnect. -- Domain atom factories route effects through the environment registry and - resolve the current scoped service at execution time. Project and thread - commands are Atom factories under `src/state` - (`createProjectEnvironmentAtoms`, `createThreadEnvironmentAtoms`), as are the - shell and thread state factories (`createEnvironmentShellAtoms`, - `createEnvironmentThreadStateAtoms`). -- Web and mobile own their Atom runtimes, React hooks, and feature composition. - -The Promise bridge exists only at the React/Atom boundary. Runtime and business -logic remain Effect-native. - -## Platform Layers - -Web and mobile provide: - -- network status and network-change streams; -- application lifecycle wakeups; -- cloud session credentials; -- device identity; -- platform registrations; -- persistent catalog, credential, shell, and thread stores; -- HTTP, crypto, and telemetry layers. - -Platform layers adapt operating-system capabilities. They do not implement -connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on -removal the registry clears its cache and calls the platform implementation, so -web clears composer drafts and mobile clears drafts plus the thread outbox. - -Mobile cloud sign-out first saves relay drafts and queued messages in the local -composer store under the owning account. These saved copies retain attachment -files during cleanup and remain outside the active composer and upload queue. -Signing back into that account restores them before relay credentials activate. -Directly paired environments keep their drafts and outbox when cloud sign-out runs. - -Mobile composer attachments upload over HTTP while their environment is connected, -with at most three concurrent transfers. Drafts retain local image data or an owned -file URI alongside the pending upload ID. Sending verifies and reuses that ID, or -uploads the local bytes again if it expired. Disconnecting cancels active transfers -without discarding drafts; reconnecting resumes preparation. Older servers without -attachment-upload support continue to receive inline images. - -## Source Boundaries - -Applications must import explicit package subpaths; the package intentionally -has no root export. The subpaths are documented in -[packages/client-runtime/README.md](../../packages/client-runtime/README.md), -with the `exports` map in that package's `package.json` as the authoritative -list. Files that are not exported are implementation details. - -## Application Boundary - -The application root mounts the shared connection layer, creates its own Atom -runtime, and selects the domain atom factories required by that platform. Web -and mobile may expose different hooks and features without changing connection -ownership. - -Application code must not construct RPC clients, retry loops, or raw -orchestration commands. Persistence paths belong to the platform registration -and cache stores, with explicit migration or invalidation policy. - -### Thread detail ownership - -Mounted detail consumers share one live thread subscription. The subscription -ends when the last consumer leaves. Hidden routes that remain mounted still -count as consumers. - -A separate registry-local atom retains the last thread state and replay cursor -for five idle minutes. A warm mount renders that state and resumes the stream -without another snapshot download. The cache holds completed state and cursor -updates together. It retains pagination data but clears canceled loading state. -An ownership token prevents an old scope from replacing its successor's cache. -Closing a thread skips snapshots that were already saved. Changed snapshots and -failed background writes still get a final save attempt. - -## Verification - -Core state-machine tests use `@effect/vitest` and deterministic service layers. -Required coverage includes: - -- offline startup and online wakeup; -- forever retry with the 16-second cap; -- explicit retry interrupting backoff; -- authentication wakeups; -- involuntary close and reconnect; -- explicit removal clearing all owned state; -- relay token reuse and refresh; -- progressive relay discovery; -- shell and thread cache hydration; -- thread stream shutdown, warm resume, and snapshot expiry; -- durable subscriptions switching sessions; -- command metadata and idempotent queued-command metadata. - -[layer]: ../../packages/client-runtime/src/connection/layer.ts -[resolver]: ../../packages/client-runtime/src/connection/resolver.ts -[driver]: ../../packages/client-runtime/src/connection/driver.ts -[registry]: ../../packages/client-runtime/src/connection/registry.ts -[supervisor]: ../../packages/client-runtime/src/connection/supervisor.ts -[session]: ../../packages/client-runtime/src/rpc/session.ts -[client]: ../../packages/client-runtime/src/rpc/client.ts +# Connection runtime + +Web, the desktop renderer, and mobile share one connection owner per environment +in `packages/client-runtime`. Platform code supplies storage, credentials, network +signals, and application lifecycle events. React views consume the runtime. +Keeping retries and session lifetime here prevents competing reconnect loops when +several views need the same environment. + +## One retry owner + +The [supervisor](../../packages/client-runtime/src/connection/supervisor.ts) owns +retry policy; resolving an endpoint and opening an RPC session are single +attempts. Transient failures retry with capped backoff. Offline states and +authentication failures wait for a wakeup instead of spending attempts on +unchanged conditions. + +Foregrounding needs different treatment depending on the connection's state. +It wakes a retry immediately, leaves an ordinary in-flight attempt alone, and +probes an established session before replacing it. A long mobile background +suspension forces replacement because the OS can kill a socket without reporting +closure. Treating every foreground event as a reconnect delays healthy attempts; +treating every resume as harmless leaves suspended sockets stuck. + +The [registry](../../packages/client-runtime/src/connection/registry.ts) scopes +connections by environment. An involuntary disconnect retains the registration +and cached data. Explicit removal closes the scope and clears credentials, +projections, and platform-owned state such as drafts. Cloud-account changes apply +to relay registrations; they must not discard directly paired environments. + +## Transport health and data freshness are separate + +A socket opening is insufficient evidence that the environment is usable. The +[RPC session](../../packages/client-runtime/src/rpc/session.ts) waits for the +initial server configuration before becoming ready. Shell and thread data then +have their own synchronization state. A failed shell subscription can coexist +with a healthy connection; labeling that state "reconnecting" promises a +transport retry that will never happen. + +Cached projections remain readable offline. They must neither imply a live +connection nor overwrite newer live data during a reconnect. Loading and +resuming snapshots belongs to the shared state services, so every view agrees +on which data is current. + +[Thread detail](../../packages/client-runtime/src/state/threads.ts) separates +subscription lifetime from cache lifetime. Mounted consumers share one live +stream, which stops when the last consumer unmounts; hidden mounted routes still +count. A registry-local cache retains state and its replay cursor for five idle +minutes so back navigation can resume without another snapshot download. + +Retain state and cursor together only after an update finishes. Cancellation must +not advance the cached cursor beyond the applied data, and an old scope must not +overwrite its successor's cache. Preserve pagination data on reuse, but clear +canceled loading state. + +The [RPC boundary](../../packages/client-runtime/src/rpc/client.ts) resolves +requests against the current session at execution time. Durable subscriptions +follow replacement sessions. After a transport failure they wait for the +supervisor; an expected domain failure may resubscribe on the same healthy +session. Reconnection does not automatically replay mutations, whose retry and +idempotency rules belong to the operation. diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 068fcfa68a90..dc43b4d45c03 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -1,197 +1,60 @@ -# Environment Authentication Profile - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -The environment server and the relay use separate credentials, issuers, and trust -boundaries. They intentionally use a similar OAuth-shaped model so that permission -checks and token exchange behavior can be audited against established concepts. - -## Authorization Model - -Environment authorization is capability-based. A session carries zero or more -OAuth-style scope strings: - -| Scope | Permission | -| ----------------------- | ------------------------------------------------------------------------ | -| `orchestration:read` | Read snapshots, status, events, configuration, and filesystem/VCS state. | -| `orchestration:operate` | Dispatch user operations and mutate environment-side workspace state. | -| `terminal:operate` | Create, attach, input, resize, clear, restart, and terminate terminals. | -| `review:write` | Read review diff previews used to compose review feedback. | -| `access:read` | Inspect pairing links and client sessions. | -| `access:write` | Create or revoke pairing links and client sessions. | -| `relay:read` | Inspect managed relay connectivity. | -| `relay:write` | Link, configure, or unlink managed relay connectivity. | - -Pairing-link lists and access-stream snapshots and updates contain metadata only. -The raw credential is returned only by the creation request, after the server checks -`access:write` and the delegated scopes. Web and desktop clients keep that response -in memory for sharing. They do not recover credentials from access read models. - -Ordinary pairing links grant the four client-operation scopes and read access to -managed relay connectivity: -`orchestration:read orchestration:operate terminal:operate review:write relay:read`. -The desktop bootstrap credential and command-line administrative bootstrap -credentials additionally grant `access:read access:write relay:write`. - -## Host file access - -Clients with `orchestration:read` can read files anywhere the environment's server account can -read, following the environment-wide authorization model rather than introducing per-project -filesystem permissions. `projects.readFile` accepts an absolute path and returns the text of that -host file; only workspace-relative paths pass its root check, and `projects.writeFile` never -accepts an absolute path. Clients use this to show files an agent wrote outside the workspace, such -as a report in a temp directory, read-only. - -## Media preview access - -Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for -supported images, videos, HTML, and PDF files anywhere the environment's server account can read. -A thread ID supplies the workspace for relative paths; absolute paths refer to the environment -host, not the client. - -[`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular -file, and validates the resolved file's literal extension. It opens the file and signs its canonical -path and device/inode identity for one hour. The token grants access to that exact file, not adjacent -files or its containing directory. Serving rechecks the canonical path, media type, and opened -descriptor's identity, then streams full or partial responses from that descriptor. Replacing a -file atomically requires a freshly signed URL; editing it in place does not. Because the token names -one file, an HTML document served this way cannot load sibling assets; the directory-scoped -`workspace-file` resource remains the route for HTML inside the workspace. Uploaded attachments keep -their separate asset resource. - -Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment -can fetch that file until it expires. Clients should copy the authored reference, not the temporary -URL. Responses use `nosniff`; SVG responses retain their restrictive sandbox policy. Video reads -support byte ranges so playback does not require a complete download first. - -Host videos can change in place, so their responses use `private, no-store` and omit `ETag` -and `Last-Modified`. File metadata cannot prove byte-for-byte identity for `If-Range`; advertising -those validators would encourage native players to send conditional seeks that require a full -response. Ordinary range requests receive partial responses. An explicitly supplied `If-Range` -still falls back to a full response because no strong validator is available. Host image previews -keep their private cache policy and weak metadata validators. - -The server serves media in place without importing it into attachment storage. Deletion makes -future server reads fail, though an already loaded client or its cache can retain bytes. Native -viewers may use temporary client-side files for display or explicit sharing; those are not durable -environment copies. - -## Authentication Flows - -### Browser Session - -`POST /api/auth/browser-session` consumes a one-time bootstrap credential and creates a -browser session cookie. The cookie is an HTTP transport adapter for the same -scoped session model; the response never exposes the session secret to browser -JavaScript. - -### Bearer Access Token - -Non-browser clients use `POST /oauth/token` with an -`application/x-www-form-urlencoded` body: - -```text -grant_type=urn:ietf:params:oauth:grant-type:token-exchange -subject_token= -subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap -requested_token_type=urn:ietf:params:oauth:token-type:access_token -scope=orchestration:read orchestration:operate terminal:operate review:write relay:read -``` - -Clients may additionally submit `client_label`, `client_device_type`, and -`client_os` extension parameters so the authorized-clients UI can identify the -device that established the session. These are presentation hints only; the -environment derives transport metadata such as IP address and user agent from -the request and does not use these fields for authorization. - -The response has the token-exchange shape: - -```json -{ - "access_token": "", - "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", - "token_type": "Bearer", - "expires_in": 2592000, - "scope": "orchestration:read orchestration:operate terminal:operate review:write relay:read" -} -``` - -Sessions issued from a plain bearer exchange use the store's -`DEFAULT_SESSION_TTL` of 30 days. The shorter one-hour `expires_in: 3600` applies -only to DPoP-bound exchanges, where the token is additionally constrained by a -proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`. - -The reusable `desktop-bootstrap` grant replaces active sessions with the same -subject and authentication method. Revocation and insertion share one database -transaction, so a failed insertion preserves the previous credential. This also -removes stale local desktop entries from earlier launches. Browser-cookie sessions -and sessions issued through pairing links are not replaced. - -Requested scopes must be a subset of the one-time bootstrap credential grant. -An ordinary paired client therefore cannot exchange its grant for -`access:read`, `access:write`, or `relay:write`. - -### DPoP-Bound Access Token - -The same `/oauth/token` exchange supports proof-of-possession tokens. A client -that sends a `DPoP` header has its proof verified by `verifyRequestDpopProof`; -the resulting JWK thumbprint is stored on the session, which is then issued with -method `dpop-access-token` and a one-hour TTL instead of the bearer default. An -invalid proof gets a DPoP challenge header and a credential error rather than a -bearer token. Newer servers include a safe `dpopFailureReason` category in that -error. When an older server omits the category, clients mention clock skew as -one possible cause rather than presenting it as confirmed. - -`dpop-access-token` is advertised alongside `browser-session-cookie` and -`bearer-access-token` in the descriptor's `sessionMethods` -(`EnvironmentAuthPolicy.ts`), so clients can discover support rather than -assume it. Relay-brokered clients use this mode so that a leaked token cannot be -replayed without the corresponding key. - -### WebSocket Ticket - -`POST /api/auth/websocket-ticket` accepts any authenticated session and returns -a short-lived, single-purpose WebSocket ticket, issued through -`EnvironmentAuth.issueWebSocketTicket` with a five-minute default TTL. The -client presents its bearer or DPoP credential in headers to get the ticket, then -appends only that ticket to the socket URL as `wsTicket`. This keeps long-lived -tokens and browser cookies out of WebSocket URLs while letting the handshake -authenticate. - -The ticket carries its session's scopes; each RPC method then enforces -`orchestration:read`, `orchestration:operate`, `terminal:operate`, -`review:write`, `relay:write`, or `access:read` as appropriate, through -`RPC_REQUIRED_SCOPES` in `apps/server/src/auth/RpcAuthorization.ts`. Review feedback submission currently dispatches -an orchestration operation, so clients performing it also need -`orchestration:operate`. Creating a ticket is not authorization to call every -RPC method. - -## Standards Alignment - -- Bearer access tokens are used through the `Authorization: Bearer` scheme from - RFC 6750. -- The token endpoint profiles the request and response vocabulary from OAuth 2.0 - Token Exchange (RFC 8693), including `subject_token`, `requested_token_type`, - `access_token`, `issued_token_type`, and `token_type`. -- Scope values follow the OAuth 2.0 scope model from RFC 6749: space-delimited, - unordered capabilities with subset checking during exchange. - -This is intentionally not a general-purpose OAuth authorization server. The -environment bootstrap token type is private, the bootstrap cookie and WebSocket -connection-token routes are product-specific adapters, and the API returns its -typed `HttpApi` errors rather than implementing every OAuth error response -surface. - -## Upgrade Behavior - -Migration `031_AuthAuthorizationScopes` is a hard cutover from role-bearing auth -records to scoped records. It deletes existing pairing links and sessions while -leaving non-authentication environment state unchanged. Upgraded clients must -pair again; old `owner` or `client` credentials are never silently mapped to new -capabilities. - -## Relay Boundary - -Relay-managed tunnels use their own tokens and keys. The relay can reuse scope -parsing and token-exchange conventions, but an environment access token is not a -relay token and cannot be presented to the relay. +# Environment authentication + +The environment issues its own sessions and enforces their capabilities. Cloud +identity and relay credentials belong to a separate trust boundary, described in +[T3 Connect](./t3-connect.md). A relay token is never an environment login. + +## Authority survives transport changes + +Pairing delegates a set of scopes. Exchanging a bootstrap credential can narrow +that grant but cannot widen it. Ordinary pairing does not grant access-management +or relay-management authority. Creating another pairing link requires both +`access:write` and every scope being delegated. The +[auth handlers](../../apps/server/src/auth/http.ts) enforce this at issuance; +client labels and device metadata have no authorization role. + +The access read model contains pairing metadata, never recoverable pairing +secrets. Only the creation response returns the raw credential. Otherwise read +access to the connections list would become a way to acquire another client's +authority. + +Browser cookies, bearer tokens, and DPoP tokens adapt the same scoped session +model. DPoP binds a token to a client's proof key; an invalid proof must fail +rather than fall back to bearer authentication. The OAuth token-exchange +vocabulary gives these grants a familiar meaning, but the environment does not +implement a general-purpose OAuth authorization server. + +Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticated +HTTP so long-lived tokens stay out of socket URLs. Browser sessions can +authenticate the upgrade with their cookie. A successful handshake grants no +extra authority: [every RPC declares a required +scope](../../apps/server/src/auth/RpcAuthorization.ts). + +Desktop restarts forget the previous local bearer token, so its reusable +bootstrap grant replaces earlier sessions for the same subject and method. +Revocation and insertion share a [database +transaction](../../apps/server/src/persistence/AuthSessions.ts); a failed +replacement must leave the old credential usable. Pairing and browser sessions +do not follow this replacement rule. + +## The environment is the filesystem boundary + +Projects are organizational boundaries, not filesystem sandboxes. +`orchestration:read` permits reading files the server account can read, including +absolute paths outside a project. This lets clients display artifacts that an +agent writes in a temporary directory. Relative paths and writes still follow +the [workspace path rules](../../apps/server/src/workspace/WorkspaceFileSystem.ts). + +Signed asset URLs are bearer credentials. A URL for media on the host grants +access to one canonical file and its device/inode identity, not its containing directory. +[Asset access](../../apps/server/src/assets/AssetAccess.ts) rechecks the opened +file's identity when serving it, so atomic replacement requires a new URL while +editing the same file in place does not. An HTML file authorized this way cannot +load sibling assets; directory-scoped workspace previews are a separate grant. +Clients should share the authored file reference so they do not disclose the +temporary URL's credential. + +Host videos can change in place. Their [HTTP +responses](../../apps/server/src/http.ts) omit cache validators because file +metadata cannot prove byte-for-byte identity for `If-Range`. Adding weak +validators would turn native-player seeks into full downloads. diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index b69c78db3298..f7cb61259c0e 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -1,211 +1,47 @@ # Glossary -> For maintainers. Using T3 Code? See [docs/user](../user/). - -This is a living glossary for T3 Code. It explains what common terms mean in this codebase. - -## Table of contents - -- [Project and workspace](#project-and-workspace) -- [Thread timeline](#thread-timeline) -- [Orchestration](#orchestration) -- [Provider runtime](#provider-runtime) -- [Checkpointing](#checkpointing) -- [Appearance](#appearance) - -## Concepts - -### Project and workspace - -#### Project - -The top-level workspace record in the app. In [the orchestration contracts][1], a project has a `workspaceRoot` and a title. It does not contain threads: `OrchestrationProject` and `OrchestrationThread` are separate arrays on the read model, and a project can have zero threads. See [workspace-layout.md][2]. - -#### Workspace root - -The root filesystem path for a project. In [the orchestration model][1], it is the base directory for branches and optional worktrees. See [workspace-layout.md][2]. - -#### Worktree - -A Git worktree used as an isolated workspace for a thread. If a thread has a `worktreePath` in [the contracts][1], it runs there instead of in the main working tree. Git operations live behind the VCS driver contract in `apps/server/src/vcs/VcsDriver.ts`, implemented by [GitVcsDriverCore.ts][3]. - -### Thread timeline - -#### Thread - -The main durable unit of conversation and workspace history. In [the orchestration contracts][1], a thread holds messages, activities, checkpoints, and session-related state. See [projector.ts][4]. - -#### Turn - -A single user-to-assistant work cycle inside a thread. It starts with user input and ends when the session leaves `running` status, which [projector.ts][4] treats as the authoritative completion signal (`settledTurnStateForSessionStatus`). Checkpoint and diff work may settle afterward without changing when the turn ended. See [the contracts][1] and [ProviderRuntimeIngestion.ts][5]. - -#### Activity - -A user-visible log item attached to a thread. In [the contracts][1], activities cover important non-message events like approvals, tool actions, and failures. They are projected into thread state in [projector.ts][4]. - -### Orchestration - -Orchestration is the server-side domain layer that turns runtime activity into stable app state. The main entry point is [OrchestrationEngine.ts][7], with core logic in [decider.ts][8] and [projector.ts][4]. - -#### Aggregate - -The domain object a command or event belongs to. In [the contracts][1], that is usually `project` or `thread`. See [decider.ts][8]. - -#### Command - -A typed request to change domain state. In [the contracts][1], commands are validated in [commandInvariants.ts][9] and turned into events by [decider.ts][8]. -Examples include `thread.create`, `thread.turn.start`, and `thread.checkpoint.revert`. - -#### Domain Event - -A persisted fact that something already happened. In [the contracts][1], events are the source of truth, and [projector.ts][4] shows how they are applied. -Examples include `thread.created`, `thread.message-sent`, and `thread.turn-diff-completed`. - -#### Decider - -The pure orchestration logic that turns commands plus current state into events. The core implementation is in [decider.ts][8], with preconditions in [commandInvariants.ts][9]. - -#### Projection - -A read-optimized view derived from events. See [projector.ts][4], [ProjectionPipeline.ts][11], and [ProjectionSnapshotQuery.ts][10]. - -#### Projector - -The logic that applies domain events to the read model or projection tables. See [projector.ts][4] and [ProjectionPipeline.ts][11]. - -#### Read model - -The current materialized view of orchestration state. In [the contracts][1], it holds projects, threads, messages, activities, checkpoints, and session state. See [ProjectionSnapshotQuery.ts][10] and [OrchestrationEngine.ts][7]. - -#### Reactor - -A side-effecting service that handles follow-up work after events or runtime signals. Examples include [CheckpointReactor.ts][6], [ProviderCommandReactor.ts][12], and [ProviderRuntimeIngestion.ts][5]. - -#### Receipt - -A typed signal emitted when an async milestone completes, such as `checkpoint.baseline.captured`, `checkpoint.diff.finalized`, or `turn.processing.quiesced`. Receipts are a test-only mechanism: the production `RuntimeReceiptBusLive` publish is a no-op and only the test layer is PubSub-backed. Do not build production behavior on them. See [RuntimeReceiptBus.ts][13] and [CheckpointReactor.ts][6]. - -#### Quiesced - -"Quiesced" means a turn has gone quiet and stable: follow-up work such as [CheckpointReactor.ts][6] has settled. It appears in [the receipt schema][13], so in practice it is something tests wait on rather than a production signal. - -### Provider runtime - -The live backend agent implementation and its event stream. The main service is [ProviderService.ts][14], the adapter contract is [ProviderAdapter.ts][15], and the overview is in [providers.md][16]. - -#### Provider - -The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, OpenCode, and Antigravity. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. - -#### Session - -The live provider-backed runtime attached to a thread. Session shape is in [the orchestration contracts][1], and lifecycle is managed in [ProviderService.ts][14]. - -#### Runtime mode - -The safety/access mode for a thread or session. [The contracts][1] define four values: `approval-required`, `auto-accept-edits`, `auto`, and `full-access`. See [permission modes][18]. - -#### Interaction mode - -The agent interaction style for a thread. In [the contracts][1], the values are `default` and `plan`. - -#### Assistant delivery mode - -Controls how assistant text reaches the thread timeline. In [the contracts][1], `streaming` updates incrementally and `buffered` accumulates text. Buffered delivery is not held until the turn completes: it spills once accumulated text would exceed 24,000 characters, and flushes at approval and user-input boundaries. See [ProviderRuntimeIngestion.ts][5]. - -#### Snapshot - -A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19]. - -#### Usage limits - -The rolling subscription quota windows a provider reports for its signed-in account, such as Claude's five-hour and weekly windows or Codex's primary and secondary allowances. Each driver decides in its own `checkProvider` whether it has any and returns them on the snapshot as `usageLimits`; drivers with no notion of subscription usage leave the field absent. Adapters that receive rate-limit telemetry during a turn normalise it into a `ProviderUsageLimitsUpdate` at the boundary, and `ProviderUsageLimitsIngestion` folds it onto the owning instance's snapshot through `ServerProviderShape.applyUsageLimits`, so no central service needs to know a driver kind. See [providerUsageLimits.ts](../../packages/contracts/src/providerUsageLimits.ts) and [makeManagedServerProvider.ts](../../apps/server/src/provider/makeManagedServerProvider.ts). - -#### Usage limit source - -A read-only quota feed outside this environment's provider CLIs, configured under `settings.usageLimitSources`. The only kind today is a CLIProxyAPI hub, whose `quota-scheduler/status` reports the windows of every pooled account. `UsageLimitSources` polls each source on the provider health interval and publishes `UsageLimitSourceSnapshot`s over the config stream as `usageLimitSourcesUpdated`, gated by a client capability flag the way environment themes are. The management key round-trips through the secret store with a redaction marker on disk. See [UsageLimitSources.ts](../../apps/server/src/usage/UsageLimitSources.ts). - -#### Model manifest - -The per-driver list of current model slugs that decides which models land in the model picker's legacy section. Bundled at `apps/server/src/provider/model-manifest.json` and refreshed at runtime from the same file on `main`, so classification updates ship as commits instead of releases. See the [provider architecture][16] model manifest section. - -### Checkpointing - -Checkpointing captures workspace state over time so the app can diff turns and restore earlier points. The main pieces are [CheckpointStore.ts][19], [CheckpointDiffQuery.ts][20], and [CheckpointReactor.ts][6]. - -#### Checkpoint - -A saved snapshot of a thread workspace at a particular turn. In practice it is a hidden Git ref in [CheckpointStore.ts][19] plus a projected summary from [ProjectionCheckpoints.ts][21]. Capture and lifecycle work happen in [CheckpointReactor.ts][6]. - -#### Checkpoint ref - -The durable identifier for a filesystem checkpoint, stored as a Git ref. It is typed in [the contracts][1], constructed in [Utils.ts][22], and used by [CheckpointStore.ts][19]. - -#### Checkpoint baseline - -The starting checkpoint for diffing a thread timeline. This flow is surfaced through [RuntimeReceiptBus.ts][13], coordinated in [CheckpointReactor.ts][6], and supported by [Utils.ts][22]. - -#### Checkpoint diff - -The difference between two checkpoints. [CheckpointDiffQuery.ts][20] reads full patches on demand. [CheckpointReactor.ts][6] uses NUL-delimited Git numstat output for automatic file summaries, parsed by [Diffs.ts][23]. - -#### Turn diff - -The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4]. - -### Appearance - -#### Environment theme - -A theme an environment's machine publishes for clients to follow, one file per theme under `themes/` in that environment's state directory; the filename is the theme id. [environmentTheme.ts][25] watches the directory and streams the set over `subscribeServerConfig`; clients render each as a library card, generating a full palette when the file carries seed colors and using the palette directly when it is a standard exported theme file. A desktop that retints its apps when the system theme changes rewrites its file, so T3 Code follows along without a restart. See [environment-theme.md][26]. - -#### Default theme - -The environment's theme, held in its `settings.json` as `defaultTheme` (with `defaultThemeSetAt` -as the set-generation) and set with `t3 theme set `. Web and desktop clients apply each set -once — live when connected, on the next connect otherwise — so setting it switches them, while a -theme a user picks in Settings afterwards sticks until the next set; mobile keeps its own -appearance settings. Naming a published [environment theme](#environment-theme) is how a desktop -ships T3 Code already matching it. - -## Practical Shortcuts - -- If you see `requested`, think "intent recorded". -- If you see `completed`, think "result applied". -- If you see `receipt`, think "async milestone signal, for tests". -- If you see `checkpoint`, think "workspace snapshot for diff/restore". -- If you see `quiesced`, think "all relevant follow-up work has gone idle". - -## Related Docs - -- [Architecture overview][24] -- [Provider architecture][16] -- [Permission modes][18] -- [Workspace layout][2] - -[1]: ../../packages/contracts/src/orchestration.ts -[2]: ./workspace-layout.md -[3]: ../../apps/server/src/vcs/GitVcsDriverCore.ts -[4]: ../../apps/server/src/orchestration/projector.ts -[5]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts -[6]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts -[7]: ../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts -[8]: ../../apps/server/src/orchestration/decider.ts -[9]: ../../apps/server/src/orchestration/commandInvariants.ts -[10]: ../../apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts -[11]: ../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts -[12]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts -[13]: ../../apps/server/src/orchestration/Services/RuntimeReceiptBus.ts -[14]: ../../apps/server/src/provider/Layers/ProviderService.ts -[15]: ../../apps/server/src/provider/Services/ProviderAdapter.ts -[16]: ./providers.md -[17]: ../../apps/server/src/provider/Layers/CodexAdapter.ts -[18]: ../user/permission-modes.md -[19]: ../../apps/server/src/checkpointing/CheckpointStore.ts -[20]: ../../apps/server/src/checkpointing/CheckpointDiffQuery.ts -[21]: ../../apps/server/src/persistence/Services/ProjectionCheckpoints.ts -[22]: ../../apps/server/src/checkpointing/Utils.ts -[23]: ../../apps/server/src/checkpointing/Diffs.ts -[24]: ./overview.md -[25]: ../../apps/server/src/environmentTheme.ts -[26]: ../user/environment-theme.md +Terms whose meaning matters across T3 Code. Architecture and lifecycle constraints belong in the +[overview](./overview.md), not in these definitions. + +## Workspace and conversation + +| Term | Meaning | +| -------------- | ------------------------------------------------------------------------------------------------- | +| Environment | One running server and the machine, credentials, workspace access, and state it owns. | +| Client | A web, desktop, or mobile UI connected to an environment. The desktop app can also host a server. | +| Project | An environment-local workspace record rooted at a directory. | +| Workspace root | The project's base filesystem directory on the environment. | +| Worktree | A separate Git checkout a thread can use instead of the project's main checkout. | +| Thread | The durable conversation and work history for a project. It survives provider process exits. | +| Turn | One user-to-agent work cycle. Provider work can finish before checkpoint and diff work settles. | +| Activity | A non-message timeline item, such as a tool action, approval, or failure. | +| T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. | + +## Orchestration + +| Term | Meaning | +| ----------------------- | -------------------------------------------------------------------------------------------- | +| Command | A request to change domain state. Accepting it does not mean its side effects have finished. | +| Event | A persisted fact produced by a command. | +| Decider | The pure logic that turns a command and current state into events. | +| Projection / read model | A view of current state derived from persisted events. | +| Projector | The logic that applies events to a read model. | +| Reactor | A worker that performs follow-up work in response to recorded intent or runtime signals. | +| Command receipt | A durable record of a command's result, used to make retries idempotent. | +| Runtime receipt | A test-only signal that an asynchronous milestone completed. | +| Quiesced | The relevant follow-up workers have finished, beyond the provider turn merely ending. | + +## Providers and checkpoints + +| Term | Meaning | +| ------------------- | ------------------------------------------------------------------------------------------------------------ | +| Provider | The agent runtime T3 Code controls, such as Codex or Claude Code. | +| Driver | The integration for a provider kind. | +| Provider instance | One configured provider, with its own settings and lifecycle. Multiple instances can use the same driver. | +| Adapter | The boundary translating a provider's native protocol into T3 Code operations and events. | +| Session | The provider runtime attached to a thread. A session can be stopped and resumed without deleting the thread. | +| Runtime mode | The thread's permission policy. See [permission modes](../user/permission-modes.md). | +| Interaction mode | How the agent approaches the task, such as planning. Separate from permission policy. | +| Checkpoint | A saved workspace state used for diffs and restore, stored as a hidden Git ref. | +| Checkpoint baseline | The workspace state captured before the work being compared. | +| Turn diff | The workspace changes attributed to one turn. | diff --git a/docs/internals/mobile-development.md b/docs/internals/mobile-development.md index e8923a4bc844..cf9132579229 100644 --- a/docs/internals/mobile-development.md +++ b/docs/internals/mobile-development.md @@ -1,50 +1,25 @@ # Mobile development lifecycle -Ordinary component changes use Metro Fast Refresh. The connection runtime uses -a stable development-only atom runtime whose writable layer atom receives the -newly evaluated Effect layer. Its module accepts the update only after installing -that layer. Existing subscribers observe the new context, and Effect releases the -previous runtime scope without restarting React Navigation. Production uses an -ordinary `Atom.runtime` without this hot-update boundary. - -Replacing the atom registry itself disposes its old nodes; replacing the managed -runtime starts its asynchronous disposal. These modules use normal Metro update -propagation. The app does not call `DevSettings.reload` during their replacement. -React can still reset state when its normal Fast Refresh rules require it. - -Do not reset the shared registry to clean up a connection-runtime edit: registry -reset removes listeners from unedited mounted consumers. Do not self-accept a -runtime module while leaving importers attached to its old implementation. A -hot-update boundary must install fresh behavior through the existing reactive -dependencies. These boundaries do not make every module-level atom family in the -app hot-swappable; new runtime singletons still need explicit ownership. Editing -the registry or managed runtime can still rebuild a much larger dependency graph -than an ordinary component or connection-runtime edit. - -Environment supervisor scopes are children of the connection registry scope. -The registry's per-environment map supports targeted shutdown, but it cannot be -the sole owner: a supervisor created after that map's finalizer has run must -still inherit the closed parent scope. Otherwise interrupted startup or runtime -replacement can leave a session and WebSocket alive outside the current registry. - -The compact Home list owns its minute-based presentation clock in a focus effect. -Blur clears the interval without a render-driving focus subscription; focus -refreshes the clock immediately. Other prop or state changes can still render -the hidden list. Exact snooze-expiry timers remain active, and the visible iPad -sidebar keeps its own minute updates. - -Connection and runtime projections are shared per environment. Thread selection -consumers should read those atoms instead of repeatedly parsing the same socket -URL or constructing new connection objects during each render. - -The Uniwind dependency patch still recompiles CSS on Metro updates so newly used -classes are discovered. It fingerprints the generated native stylesheet and -theme list, then skips development-only global invalidation when that output is -unchanged. A changed stylesheet or theme list still resets the style caches and -notifies subscribers. The digest is committed only after initialization succeeds. -Web and production retain their existing initialization behavior. - -After installing or changing the Uniwind patch, restart Metro once with -`vp run dev:client:reset` from `apps/mobile`. pnpm gives patched packages new -filesystem paths, and cached transforms can otherwise retain references to the -previous package. Ordinary development starts should retain the transform cache. +The [connection runtime's HMR boundary](../../apps/mobile/src/lib/hot-swappable-atom-runtime.ts) +keeps a stable atom runtime and replaces its Effect layer through a writable atom. +It accepts the update only after installing the new layer. Otherwise importers +retain the old behavior even though Metro reports a successful refresh. + +Do not reset the shared atom registry to refresh a connection-runtime edit. Reset +removes listeners from mounted consumers that Metro has no reason to rerender. +When the registry or managed-runtime module itself changes, normal Metro propagation +disposes its old resources. This boundary does not make arbitrary module-level atom +families safe to hot-swap. Production uses an ordinary atom runtime. + +[Environment supervisor scopes](../../packages/client-runtime/src/connection/registry.ts) +are children of the registry scope. The per-environment map supports targeted +shutdown, but a supervisor created after its cleanup runs would escape it. A closed +parent scope also closes late arrivals, preventing interrupted startup or runtime +replacement from leaving a WebSocket alive outside the new registry. + +The [Uniwind patch](../../patches/uniwind@1.11.0.patch) still compiles CSS on Metro +updates so newly used classes are discovered. It skips global style invalidation +only when the generated stylesheet and theme list are unchanged. Skipping compilation +would lose new classes; invalidating every consumer for unchanged output makes an +ordinary component edit refresh the whole app. The fingerprint is recorded only +after initialization succeeds. diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index c2528219ac6b..94bd550d142d 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -1,138 +1,56 @@ # Mobile navigation -The iOS Home and thread routes share the root native stack in -[`Stack.tsx`](../../apps/mobile/src/Stack.tsx). Keeping them in one navigation -controller lets UIKit animate the header between routes. The iPad sidebar owns -a separate, single-screen stack; Android uses its own in-flow headers. - -Home and the iPad sidebar render their brand and connection status through -`headerTitle`, with `Threads` retained as the route title. The editor-style native -bar aligns that title on the leading side. Do not model the brand as a toolbar -button: on iOS 26.5 UIKit morphs a background-free custom leading item's rectangle -into the next screen's glass back button, even with distinct item identifiers. -Using the title slot lets the brand and native back button animate separately. - -The connection-status title has a maximum width based on its header's width and -trailing actions. A long environment name or larger text must not push Settings -into UIKit's overflow menu. Keep the full status as the accessibility label -while visually truncating it. The iPad sidebar uses its pane width, not the full -window width. - -On iOS 26, UIKit does not recognize Fabric's custom text views when shaping the -native scroll-edge fade. The screens patch gives custom title subviews an empty, -non-interactive native label matching their bounds. This supplies the fade's -geometry without drawing anything or turning the title back into a toolbar item. -Check the top scroll fade as well as push/pop when changing these title views. - -The react-native-screens patch caches leading, trailing, and center item groups -independently. A button can belong to only one group: constructing another group -with the same button removes it from the previous one. Unrelated menu updates -must therefore preserve the other groups while UIKit may be animating them. - -Each cache includes the owning header config, its item values, and custom native -item identities, so changed content or remounted event emitters still rebuild. - -Custom header identifiers require native code generation and a new mobile build; -an over-the-air JavaScript update alone is insufficient. The Android view manager -implements the generated identifier setter as a no-op because this behavior is -specific to iOS 26 and later. - -On iOS 26 the full-screen back swipe is UIKit's `interactiveContentPopGestureRecognizer`, -and react-native-screens makes it wait for any horizontally scrollable ScrollView to -fail first. Upstream applies that to every horizontal ScrollView regardless of -position, so a back swipe on a code block or table that is already at its leading -edge only bounces. The patch narrows the rule to ScrollViews with content still -hidden to the left; at the leading edge they yield to the pop gesture like plain -text does. - -After changing a dependency patch, refresh CocoaPods before rebuilding an -existing iOS project. pnpm installs each patch hash in a different directory; -an old Pods project can keep compiling the previous directory even though Metro -and `apps/mobile/node_modules` resolve to the new patch. - -`ControlPillMenu` resolves semantic icon colors through `withUniwind` and supplies them to every -iOS `MenuView` action, including nested actions. The menu library's Fabric bridge converts a missing -`imageColor` to transparent, so callers should use this wrapper instead of -rendering `MenuView` directly. Explicit colors are preserved, and destructive -actions default to the theme's danger foreground color. Native stack header menus -use a separate implementation and do not need this workaround. +Home and thread routes share the [root native stack](../../apps/mobile/src/Stack.tsx) +so UIKit can animate the header within one navigation controller. The iPad sidebar +has its own stack. Splitting Home and threads between controllers loses that +continuous transition. + +## UIKit constraints + +The [react-native-screens patch](../../patches/react-native-screens@4.26.2.patch) +preserves behavior that is easy to break when changing native headers: + +- The brand belongs in `headerTitle`. On iOS 26.5, UIKit morphs a background-free + leading toolbar item's rectangle into the next screen's glass back button, + even when the items have different identifiers. +- UIKit's scroll-edge fade does not recognize Fabric text views. An empty native + label supplies the custom title's geometry. Removing that apparently unused + view changes the fade. +- Leading, trailing, and center item groups need independent caches. A button can + belong to only one group; rebuilding an unchanged group can pull its buttons + out of a group UIKit is still animating. Cache ownership also includes the + header config, whose event emitter changes on remount. +- A horizontal ScrollView at its leading edge must yield to the full-screen back + gesture. Upstream gives every horizontal ScrollView priority, so swiping back + on a code block or table can only bounce its content. + +The patch adds native header props that require code generation and a new binary. +Patch rebuild guidance lives in the +[mobile development README](../../apps/mobile/README.md#development). ## Native media presentations -`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit, -image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child -and pass the stable identifier to the presentation. The registry keeps weak -references to source views; recycled or compact composer thumbnails can register -the same identifier. Identifiers must distinguish simultaneously visible attachments. -The source registration does not own the preview. Android uses a regular view. - -On iOS, video previews mount `AVPlayerViewController` temporarily inside the -registered source and enter full screen through AVKit. AVKit -owns that zoom, its playback controls, Close button, and interactive dismissal. -Do not replace AVKit's transition with `preferredTransition`: in the iOS 27 -simulator, that leaves native Close unable to exit full screen. When the source is unavailable, -the player uses a standard modal presentation. Programmatic entry uses the same -guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; -if that selector is unavailable, the player also falls back to a standard modal. - -`FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset, -or a retained composer file. On iOS, Quick Look owns image and document layout, controls, -zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail -and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or -another image scroll view over that presentation: Quick Look coordinates its image gestures -with the return to the thumbnail. Missing sources use the standard transition, and Reduce -Motion disables animation. A pending programmatic Close waits until the current presentation -or cancelled dismissal has settled before starting another transition. - -The shared native presenter copies original bytes into its own temporary directory and -removes that copy after dismissal. Network downloads write to disk, and sharing never edits -the source attachment. Draft images use their stored upload data rather than a potentially -expired picker URI. No React Navigation route or custom transition animator is needed. - -The same viewer handles message images, markdown images, PDF attachments and links, -composer thumbnails, and workspace image previews. The workspace PDF web preview has an -Open PDF action for the native viewer. Android retains its image viewer and uses the -system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. - -Every full-screen video preview on iOS is AVKit, playing directly from the signed asset -URL: sent attachments, composer drafts, and file links that point at a video. Inline -markdown embeds and the workspace file screen play in place with the Expo Video player. -AVKit handles buffering; the client does not download the entire file or show a separate -opening overlay before presentation. -The URL is captured once per preview so credential refresh does not restart playback. -Media actions (copy path, open in file viewer, save or share) belong to the surface that -opened the video, a long-press on the thumbnail, so the player carries no -menu. AVKit has no retry; a failure alerts and closes, re-mints the URL in the background, and the next open plays from the fresh one. - -The native presentation promise completes after dismissal. Local draft previews -hold their file lease until that promise settles. The iOS preview component requests -native dismissal when its source screen unmounts. Playback pauses in the background. -AVPlayer activates audio as playback starts. The presenter pauses and releases -its own player on close, then restores the previous audio-session configuration -if no other component changed it during playback. It does not deactivate the -shared session, which may still serve another player or recorder. Android plays every -video, attachments included, in its React Native modal with the Expo Video player. -Composer drafts play and share from their local file; other videos stream from the -signed URL and download only for save or share. - -`shareFileFromSource` uses the same source registration to anchor UIKit's activity -controller. Its promise completes when the native share flow finishes, keeping -the existing attachment lease and foreground handoff active for that duration. -Android uses Expo Sharing. On iOS, received and draft video attachments expose -Save or share through `VideoAttachmentMenu`. The attachment supplies the source -identifier, and the native share presentation inherits its appearance. AVKit's -iOS playback controls do not expose a public custom-share-action API. - -Video attachment thumbnails use Expo Video's native frame extraction and Expo Image. -Received attachments use their signed asset URL; drafts retain and resolve their local file -until extraction ends. Extraction is serial; temporary players never play or change audio settings. Leaving the screen cancels -pending work; a 15-second limit prevents an unreachable source from holding up the queue. -The client keeps at most 32 native images, each bounded to 480 pixels per side, keyed by -environment and attachment identity rather than expiring URLs. Images still displayed keep -their own references when evicted from that cache. - -The asset HTTP route supports single byte ranges for videos so iOS can read metadata and -frames without first downloading the whole file. Normal downloads keep their full response; -unsupported ranges and conditional `If-Range` requests also fall back to the full file. -An older environment without range support may still show the play-card fallback. Thumbnail -failure never disables playback or sharing. +iOS delegates full-screen video to [AVKit](../../apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift) +and images and documents to [Quick Look](../../apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift). +Each framework owns its controls and transition. A separate UIKit zoom transition +prevents AVKit's native Close action from exiting full screen and interferes with +Quick Look's interactive return to its thumbnail. The AVKit entry selector is +guarded, with standard modal presentation as the fallback. + +[Thumbnail registration](../../apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift) +holds weak view references for transition and share-sheet anchors. It does not own +the preview; a source row can disappear while a native presentation is open. +Identifiers must distinguish attachments that are visible at the same time. +Native promises complete after dismissal so callers keep local file leases until +the preview or share flow has finished. + +Treat a dismissal request as pending until UIKit finishes its current transition. +Starting another dismissal while presentation or an interactive dismissal is +settling can strand the controller. Video cleanup restores the previous audio +configuration only if it still matches the preview's configuration. It must not +deactivate the shared audio session, which may belong to another player or recorder. + +Video playback holds the first signed asset URL for the lifetime of the preview. +Following credential refreshes reactively would restart playback. Quick Look copies +original bytes into its own temporary directory so preview and sharing cannot +mutate a draft or workspace file. diff --git a/docs/internals/model-manifest.md b/docs/internals/model-manifest.md index d5be97afe92b..3892a1df7277 100644 --- a/docs/internals/model-manifest.md +++ b/docs/internals/model-manifest.md @@ -1,42 +1,25 @@ # Model manifest -`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from -`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic -catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the -last successful remote manifest. The bundle is used when no valid remote cache exists, or when -the bundle's `updatedAt` is later than the cached manifest's, so a release that edits the manifest -takes effect before the next successful fetch. Bump `updatedAt` whenever you edit the file. - -The top-level provider catalog is generic: models contain presentation metadata, aliases, status, -an optional badge, and a reusable capability profile. The profile and model `adapter` fields are -opaque until the owning provider validates them with its own allowlisted schema. - -Claude Code uses the manifest as its complete built-in model catalog. To add a Claude model that -uses an existing profile, add one object to `providers.claudeAgent.models`. Do not add a test or -change application code. Add or change a profile in the same JSON file only when the model exposes -a capability combination that does not already exist. - -`currentModels.claudeAgent` is retained as a frozen compatibility field for releases that predate -catalog discovery. New Claude models do not need to be added there. Codex still discovers models -from its app server and uses `currentModels.codex` only as a legacy-classification overlay. - -Claude model entries support: - -- `aliases`, `status`, `badge`, and `profile` for client presentation and selection. -- `adapter.claudeCode.minVersion` and `maxVersionExclusive` for installed-runtime compatibility. -- Profile-level effort mappings, model suffixes, and context-window metadata for dispatch. - -## Test policy - -Changing model data does not require tests. Do not add or update tests for a model slug, display -name, alias, legacy status, version boundary, badge, or profile assignment. The bundled manifest is -configuration and is validated by its schema when imported. - -Add tests only when implementation behavior changes: - -- Fetching, caching, fallback, or schema-version handling changes in the manifest service. -- Provider-neutral profile resolution gains new semantics. -- A provider adapter gains a new compatibility or dispatch mapping type. - -Resolver tests must use synthetic providers and model names so normal JSON edits never create test -churn. +The [bundled manifest](../../apps/server/src/provider/model-manifest.json) allows +offline startup; fetching it from `main` lets model metadata change between +releases. Failed fetches or invalid data preserve the last usable manifest. +Remote data must pass both catalog-reference validation and the owning provider's +adapter validation before replacing the cache. + +A newer bundle outranks the cached remote manifest by `updatedAt`, so a release can +correct model data before the next successful fetch. Bump `updatedAt` whenever the +file changes. Fetch time cannot establish which copy contains the newer edit. + +Generic catalog data describes presentation and capabilities. Each provider owns +its adapter schema and dispatch mappings. Claude uses the manifest for its entire +built-in catalog. Adding a model with an existing capability profile is a JSON +edit; a new profile is needed only for a new capability combination. Codex still +gets its model list from its app server. + +`currentModels.claudeAgent` is frozen for releases that predate catalog discovery. +Do not extend it when adding Claude models. Codex uses `currentModels.codex` as a +legacy-classification overlay for discovered models. + +Model data is schema-validated configuration. Tests should cover resolver, cache, +and adapter semantics with synthetic model names, so adding a model never requires +tests that repeat the configuration. diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 3affe10c48f7..7d4f2d0170e1 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -1,179 +1,67 @@ # Architecture -> For maintainers. Using T3 Code? See [docs/user](../user/). - -T3 Code is a server runtime that owns agent sessions, workspaces, and version control, plus clients -(web, desktop, mobile) that talk to it over one authenticated Effect RPC WebSocket. The server is the -execution boundary: every provider process, terminal, git operation, and filesystem read happens -there, never in the client. - -``` -┌────────────────────────────────────────────────┐ -│ Clients: apps/web, apps/desktop, apps/mobile │ -│ shared runtime: packages/client-runtime │ -│ connection supervisor, RPC session, Atom state│ -└──────────────────┬─────────────────────────────┘ - │ Effect RPC over WebSocket (/ws) - │ contract: packages/contracts -┌──────────────────▼─────────────────────────────┐ -│ apps/server │ -│ orchestration engine (event-sourced) │ -│ provider driver registry (6 built-in drivers) │ -│ checkpointing, VCS, terminals, filesystem │ -└──────────────────┬─────────────────────────────┘ - │ per-driver transport -┌──────────────────▼─────────────────────────────┐ -│ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode, Antigravity │ -└────────────────────────────────────────────────┘ -``` - -## The RPC boundary - -The client/server contract is an Effect RPC group, not a hand-rolled push protocol. [`rpc.ts`][rpc] -declares `WS_METHODS` and assembles `WsRpcGroup`; each member is either unary or a server stream -(`stream: true`). Streaming members such as `orchestration.subscribeShell`, -`orchestration.subscribeThread`, `subscribeServerConfig`, and `terminal.attach` replace what used to -be a broadcast push bus: a client subscribes to what it needs and the server pushes only on that -subscription. - -[`ws.ts`][ws] serves the group. `websocketRpcRouteLayer` mounts `GET /ws`, authenticates the upgrade -through `EnvironmentAuth.authenticateWebSocketUpgrade`, then hands the socket to -`RpcServer.toHttpEffectWebsocket`. Authorization is per method: `RPC_REQUIRED_SCOPE` maps each method -to a scope, and `authorizeEffect`/`authorizeStream` enforce it. Holding a valid socket is not -authorization to call everything on it. See [environment-auth.md](./environment-auth.md). - -On the client, [`session.ts`][session] opens the socket and builds the typed client. -`RpcSessionFactory` is the service; a session exposes `client`, `initialConfig`, `ready`, `probe`, -and `closed`. It performs one attempt and does not retry. Retry, backoff, and offline policy belong -to the connection supervisor. - -## Shared client runtime - -`packages/client-runtime` holds every non-visual client concern: connection lifecycle, -authentication, RPC, cached environment data, and domain state as Atom factories. Web and mobile -compose it the same way (`apps/web/src/connection/runtime.ts` and -`apps/mobile/src/connection/runtime.ts` mirror each other, differing only in platform-specific -background-activity layers) and differ beyond that only in the platform layer they supply and the -UI they build on top. React components never construct transports, retry loops, -or RPC clients. See [connection-runtime.md](./connection-runtime.md). - -## Orchestration is event-sourced - -The server does not mutate app state directly. Clients dispatch typed commands; the engine turns them -into persisted events; projections derive the read model. - -[`OrchestrationEngine.ts`][engine] serializes this. `dispatch` offers a `CommandEnvelope` onto -`commandQueue` and awaits its result; a single worker fiber takes envelopes one at a time, so command -processing is totally ordered. For each envelope `processEnvelope`: - -1. checks the durable command receipt, making retries idempotent; -2. runs `decideOrchestrationCommand` ([`decider.ts`][decider]) to produce events from command plus - current state, pure and side-effect free; -3. inside one SQL transaction, appends events to the event store, applies them to the in-memory read - model via [`projector.ts`][projector], projects them into persisted tables, and writes the - accepted receipt; -4. after commit, swaps in the new read model, cleans up attachments, and publishes committed events - to subscribers. Attachment cleanup failures are logged and do not reject committed commands. - -Because persistence and projection share a transaction, the read model cannot durably disagree with -the event log. On dispatch failure the engine rereads persisted events past the starting sequence and -reconciles. - -Command and event names live in [`orchestration.ts`][contracts]. Some commands are client -dispatchable (`thread.create`, `thread.turn.start`, `thread.approval.respond`); others are internal -and produced only by server-side reactors (`thread.message.assistant.delta`, -`thread.turn.diff.complete`). - -A turn is complete when its session leaves `running` status, projected by -`settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later -does not define turn end. - -Thread settlement is server-owned. Each server's own settings control PR and inactivity -settlement. Those keys are user preferences, so clients write them to every shared-settings sync -target (`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and -warn when another target drifts. A target must have an active connection and advertise the -`threadAutoSettlement` capability, which signals that the server can hold every shared key. -[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and -once per minute, including when no client is connected. It dispatches the guarded internal -`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic -settlement excludes live background work and requires a comparable PR timestamp for immediate PR -settlement. The command carries the latest activity timestamp and rejects any later event for its -thread after the reactor's snapshot. The sweep looks a branch up from the thread's worktree when it -still exists, so it shares the per-cwd PR cache the sidebar polls instead of spending a second host -request. Clients render the persisted settlement state and do not derive settlement from PR or -inactivity state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle -provider session. - -At turn completion, `CheckpointReactor` refreshes PR discovery when the checkout matches the -thread's non-default branch. `VcsStatusBroadcaster` requires loaded remote status and permission -from background policy. `GitManager` retries only a successful "no PR" cache entry for the current -branch, preserving known PRs and failure backoff without fetching remotes. Remote status reads -that write the broadcaster cache share a lock per cwd, including the initial status load. - -## Drainable workers - -Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]: -[`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands, -[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, -[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints, and -[`ThreadSettlementReactor`][settlement] evaluates server-owned automatic settlement rules. - -`DrainableWorker` pairs a transactional queue with a transactional count of outstanding items. -`enqueue` atomically offers and increments; processing always decrements. `drain` retries until the -count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping. -Each of these four services exposes `drain` for exactly this. - -Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in -[`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not -build production behavior on receipts. - -## Provider drivers - -Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, OpenCode, and Antigravity. A driver declares its kind and config schema and creates a -scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves -an instance to its adapter, so `ProviderService` routes session and turn operations without knowing -which agent is behind them. See [providers.md](./providers.md). - -## Checkpointing - -Each turn is bracketed by workspace checkpoints so diffs and reverts are exact. `CheckpointStore` -captures state as hidden Git refs through the VCS driver's checkpoint operations; -`CheckpointDiffQuery` answers turn and full-thread diff requests; `CheckpointReactor` coordinates -baseline capture, completed-turn capture, diff projection, and reverting both the workspace and the -provider conversation. The storage contract is `VcsCheckpointOps` in -[`VcsDriver.ts`](../../apps/server/src/vcs/VcsDriver.ts), implemented for Git in the same directory. - -## Startup - -[`serverRuntimeStartup.ts`][startup] runs a fixed lifecycle: start keybindings, settings, and -reactors; publish welcome; signal command readiness (logged as `Accepting commands`); wait for the -HTTP listener via `markHttpListening`; publish ready; fork the heartbeat; then either print headless -output or open the browser. Command readiness precedes the listener, so a socket that opens can -already dispatch. - -## Related - -- [Workspace layout](./workspace-layout.md), [Glossary](./glossary.md) -- [Mobile navigation headers](./mobile-navigation.md) -- [Remote environments](./remote.md), [Server updates](./server-updates.md) -- [Resource telemetry](./resource-telemetry.md) -- [Product analytics](./product-analytics.md) -- [Scripts](./scripts.md), [CI gates](./ci.md) - -[rpc]: ../../packages/contracts/src/rpc.ts -[contracts]: ../../packages/contracts/src/orchestration.ts -[ws]: ../../apps/server/src/ws.ts -[session]: ../../packages/client-runtime/src/rpc/session.ts -[startup]: ../../apps/server/src/serverRuntimeStartup.ts -[engine]: ../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts -[decider]: ../../apps/server/src/orchestration/decider.ts -[projector]: ../../apps/server/src/orchestration/projector.ts -[worker]: ../../packages/shared/src/DrainableWorker.ts -[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts -[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts -[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts -[settlement]: ../../apps/server/src/orchestration/ThreadSettlementReactor.ts -[receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts -[drivers]: ../../apps/server/src/provider/builtInDrivers.ts +T3 Code keeps execution in the environment that owns the workspace. Web, desktop, and mobile +clients control it over authenticated RPC. A remote client must never substitute its own filesystem, +provider credentials, or machine state for the environment's. The desktop app bundles a server, +but its renderer follows the same boundary. + +## Ownership boundaries + +Provider processes, terminals, Git, and project files belong to the server. Shared connection and +domain state belongs in `packages/client-runtime`; clients supply platform services and UI. +Keeping that logic shared prevents reconnect and multi-environment behavior from diverging between +web and mobile. See [connection runtime](./connection-runtime.md) and +[remote environments](./remote.md). + +The [RPC contract](../../packages/contracts/src/rpc.ts) is the boundary between independently +versioned clients and servers. Subscriptions send the state a client needs, so a client viewing one +thread does not pay for every thread's history. Authentication of a socket does not authorize every +method on it. See [environment auth](./environment-auth.md). + +Provider-specific behavior belongs behind an adapter. Orchestration works with normalized commands +and events, so adding a provider should not require branches throughout the domain or clients. +See [provider constraints](./providers.md). + +## Durable intent and side effects + +The event log is the source of truth for orchestration state. The +[engine](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts) serializes commands; +the [decider](../../apps/server/src/orchestration/decider.ts) produces events without performing +provider or filesystem work. Events, persisted projections, and the accepted command receipt commit +in one database transaction. The in-memory state changes and subscribers receive events after that +commit. This keeps command retries idempotent and prevents a persisted projection from getting +ahead of the event log. + +Reactors perform side effects after intent has been recorded, then feed results back through +commands. A command acknowledgement therefore means the intent committed, not that the provider, +checkpoint, or other follow-up work finished. Keep external I/O out of the decider and the database +transaction. + +Persisted events must remain decodable on replay. Changing a schema affects old environments at +startup as well as live RPC traffic. Compatibility work must account for stored history, not just +what the newest client sends. + +## Turn completion and checkpoints + +A turn ending and its follow-up work settling are separate milestones. The +[projector](../../apps/server/src/orchestration/projector.ts) settles the turn from its session +status. A late checkpoint or diff must not extend the recorded turn duration or keep the client +showing provider work as active. + +[Checkpoints](../../apps/server/src/checkpointing/CheckpointStore.ts) use hidden Git refs to +capture workspace state without adding commits to the user's branch. A revert must coordinate +workspace state with the provider conversation. A provider that cannot roll back its conversation +must reject that operation before changing the filesystem. + +## Waiting for asynchronous work + +Tests use [drainable workers](../../packages/shared/src/DrainableWorker.ts) to wait until both the +queue and its current item have finished. An empty queue alone does not prove the worker is idle. + +Runtime receipts mark specific test milestones. Their +[production layer](../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts) is a no-op; +production behavior must use persisted state and events. These test signals are separate from the +durable command receipts that make dispatch idempotent. + +See the [glossary](./glossary.md) for shared terms and the +[development runbook](../operations/development.md) for setup and checks. diff --git a/docs/internals/product-analytics.md b/docs/internals/product-analytics.md index b1116986bf32..0e88610ada99 100644 --- a/docs/internals/product-analytics.md +++ b/docs/internals/product-analytics.md @@ -1,177 +1,49 @@ # Product analytics -T3 Code sends anonymous product events from the server to PostHog. The server -uses the first available hashed Codex account ID, hashed Claude user ID, or -installation-scoped anonymous ID as the distinct ID. It also keeps the -telemetry opt-out, event buffer, and batch delivery. Clients do not load the -PostHog browser SDK. - -## Client events - -These events use the metadata from the WebSocket connection that caused them. -The metadata is not a person property or server-global current-client value. -Two clients connected to one server can report different values at the same -time. - -| Event | Description | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `client.connected` | The server accepted an authenticated WebSocket connection. Reconnects count again. Use this event for connection diagnostics, not active-use counts. | -| `client.thread.started` | The server accepted a command that created a thread. | -| `client.turn.requested` | The server accepted a turn request. This is the standard active-use event. | - -`provider.turn.sent` stays a provider execution event. It does not receive -client metadata because a provider turn can continue after the requesting -client disconnects. - -## Provider turn events - -`provider.turn.completed` records one anonymous event when a provider emits a -canonical completed or aborted turn. The event reports normalized main-agent -token counts when the provider supplies enough data. - -| Property | Values and meaning | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `provider` | Provider driver, such as `codex`, `claudeAgent`, or `opencode`. Use this as the harness breakdown. | -| `model` | Selected or provider-reported model when known. This is metadata, not proof that one model produced every token. | -| `effort` | Selected or provider-reported reasoning effort when known. | -| `interactionMode` | Turn interaction mode, `default` or `plan`, when known. | -| `runtimeMode` | Session permission mode when known. | -| `mixedModels` | `true` when T3 Code observed `model.rerouted` during the turn. This does not split token counts by model. | -| `durationMs` | Time from the observed turn start or accepted send request to the terminal event. Omitted when start metadata is not available. | -| `terminalStatus` | `completed`, `failed`, `interrupted`, or `cancelled`. | -| `usageStatus` | `complete`, `partial`, or `unavailable`. | -| `usageScope` | `main_agent`. Child-agent token use is not included. | -| `hasSubagents` | Whether the adapter observed child agents during the turn. Omitted when the adapter does not provide a normalized usage report. | -| `inputTokens` | Total main-agent input tokens. This includes uncached input, cache reads, and cache creation or writes. | -| `cachedInputTokens` | Cache-read tokens. This is a subset of `inputTokens`. | -| `cacheCreationTokens` | Cache creation or write tokens. This is a subset of `inputTokens`. | -| `outputTokens` | Total main-agent output tokens, including reasoning tokens. | -| `reasoningTokens` | Reasoning tokens when the provider reports them separately. This is a subset of `outputTokens`. | - -`complete` means the provider supplied full input and output totals for the -turn. `partial` means each included count is valid, but T3 Code did not observe -the full turn. This can happen after an interruption, provider failure, or -reconnect. `unavailable` means the provider did not supply trustworthy counts. -Numeric properties are omitted when their values are unknown. They are not set -to zero. - -Codex uses deltas from its cumulative token totals, and falls back to the -newest response's `last` usage when no prior total exists or the total was -reset. Claude uses the final result's per-turn `usage` value and does not use -cumulative `modelUsage`. -OpenCode sums unique main-session step totals. Cursor, Grok, and Antigravity -usage stays `unavailable` until their ACP token fields and scope are verified. - -The completed event count does not have to match `provider.turn.sent`. For -example, Claude can emit synthetic turns without a matching send request. -Duplicate terminal events for the same provider instance, thread, and turn -produce one analytics event. - -Collection is best effort and starts with the first server release that -contains this event. T3 Code does not scan provider history or backfill older -turns. - -## Recommended properties - -Client properties appear on the three client events when the connected client -reports them. Older clients can omit every client property. - -| Property | Values and meaning | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `surface` | Product client: `web`, `desktop`, or `mobile`. | -| `webDeployment` | Web delivery: `hosted` for the hosted app or `server` for web files served by a T3 server. Web only. This does not describe connection distance. | -| `clientOs` | `macOS`, `Windows`, `Linux`, `iOS`, `Android`, `ChromeOS`, `other`, or `unknown`. | -| `clientDeviceType` | `desktop`, `phone`, `tablet`, or `unknown`. This is separate from `surface`. | -| `clientBrowser` | Normalized browser family. Web only. Browser detection is best effort. | -| `clientAppVersion` | Version of the connected client. | -| `clientOsMajorVersion` | Client OS major version when the native client reports it. Initially mobile only. | -| `clientDeviceModel` | Hardware model when the native client reports it. Initially mobile only. This is not a user-assigned device name. | -| `connectionMethod` | `direct`, `ssh`, `relay`, or `unknown`. `direct` means that the client connected to the server endpoint without an SSH or relay connection. It does not mean both processes run on one machine. | - -Server properties appear on all events, including server boot and background -events. - -| Property | Values and meaning | -| ------------------ | -------------------------------------------------------------- | -| `serverOs` | Server process OS, normalized to the same names as `clientOs`. | -| `serverArch` | Server process architecture. | -| `serverWslDistro` | WSL distribution from `WSL_DISTRO_NAME`, when present. | -| `serverAppVersion` | T3 server version. | -| `serverMode` | Server runtime mode: `desktop` or `web`. | - -## Legacy properties - -Existing property meanings do not change: - -- `clientType` describes how the server runs. It is `desktop-app` for a desktop - server and `cli-web-client` for a CLI web server. It does not describe the - connected client. Use `surface` and `webDeployment` for new reports. -- `platform`, `arch`, `wsl`, and `t3CodeVersion` describe the server. Use the - new `server*` names for new reports. -- `appVersion` describes the connected client. Use `clientAppVersion` for new - reports. -- Mobile connection events keep `os`, `osMajorVersion`, and `deviceModel`. - Use the new `client*` names for new reports. - -## PostHog dashboard - -Create one saved dashboard named `Client and platform usage`. Set -`client.turn.requested` as the event for active-use reports. A user can appear -in several client groups during one period, so do not add breakdown values to -calculate a total. - -Save these insights: - -| Insight | Configuration | -| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Active users, daily | Trends, `client.turn.requested`, unique users, daily interval. | -| Active users, weekly | Trends, `client.turn.requested`, unique users, weekly interval. | -| Active users, monthly | Trends, `client.turn.requested`, unique users, monthly interval. | -| Client usage | Trends, `client.turn.requested`, unique users. Save four filtered series: `surface = desktop`, `surface = mobile`, `surface = web` and `webDeployment = hosted`, and `surface = web` and `webDeployment = server`. Name them Desktop, Native mobile, Hosted web, and Server-served web. | -| Client OS, active users | Trends, `client.turn.requested`, unique users, breakdown by `clientOs`. | -| Client OS, turns | Trends, `client.turn.requested`, total events, breakdown by `clientOs`. | -| Client versus server OS | Table, `client.turn.requested`, breakdown by `clientOs` and `serverOs`. | -| Connection method, active users | Trends, `client.turn.requested`, unique users, breakdown by `connectionMethod`. | -| Connection method, turns | Trends, `client.turn.requested`, total events, breakdown by `connectionMethod`. | -| Mobile devices | Table, `client.turn.requested`, filter `surface = mobile`, breakdown by `clientOs`, `clientOsMajorVersion`, and `clientDeviceType`. | -| Client version adoption | Trends, `client.turn.requested`, unique users, breakdown by `clientAppVersion`. | -| Server version adoption | Trends, `client.turn.requested`, unique users, breakdown by `serverAppVersion`. | -| Missing metadata | Table or SQL insight that shows the percentage of `client.turn.requested` events where each of `surface`, `clientOs`, `clientDeviceType`, `clientAppVersion`, and `connectionMethod` is absent. Track `webDeployment` and `clientBrowser` only within `surface = web`. | - -In PostHog Data management, use the event and property descriptions from this -document. Mark the recommended properties as verified. Keep `clientType` -visible with its legacy description so old reports remain understandable. - -Create a second saved dashboard named `Provider token efficiency`. Start each -token insight with these filters: - -- `usageStatus = complete` -- `hasSubagents = false` -- `mixedModels = false` - -Break down input tokens, output tokens, and their ratio by `provider`. Compare -the same `model`, `effort`, `interactionMode`, and `terminalStatus`. Without -these filters, model choice, reasoning level, plan mode, failures, and child -agents can look like harness differences. Keep separate views for median turn -usage and aggregate `sum(outputTokens) / sum(inputTokens)`. Do not average each -turn's ratio because small-input turns can dominate that result. - -## Collection and release boundary - -Client values are best effort. Invalid values are ignored and never reject a -connection. Browser clients use user-agent data for broad OS, browser, phone, -and tablet groups. They do not infer CPU architecture or an exact OS version -from `navigator.platform`. - -This change does not collect URLs, authentication tokens, prompts, IP -addresses, or user-assigned device names. The token event does not include -thread IDs, turn IDs, provider instance IDs, event IDs, request IDs, raw -provider payloads, or child-agent output. It does not scan provider history on -disk or start external work. It measures authenticated product use. It does -not measure a person who visits the hosted app without connecting to a server. - -The new fields start with the first client and server release that contains -this metadata path. Historical events cannot reliably identify the client OS, -hosted web use, device type, or connection method when the old client did not -send those fields. Reports must treat missing values as pre-release or older -client data instead of backfilling them from server fields. +The server owns PostHog delivery, opt-out, and identity for every connected client. +[Identity selection](../../apps/server/src/telemetry/Identify.ts) hashes an available +provider account ID, falling back to an installation-scoped ID. This identity can +span several clients; it does not identify a browser session. Clients do not load +the PostHog browser SDK. Client-use events require an authenticated connection, so +visiting the hosted app without connecting does not count as product use. + +## Attribution boundaries + +Client dimensions belong to the event's WebSocket connection. A server-global +"current client" would misattribute simultaneous web, desktop, and mobile use. +Provider execution has its own events because a turn can outlive the requesting +connection. + +Keep client and server dimensions separate. A desktop host can serve a phone or a +remote browser, and a direct connection can cross a network. Older clients omit +metadata. Missing client values must stay unknown rather than being backfilled +from server properties. The legacy `clientType` property describes how the server +runs; use `surface` for the connected client. + +## Interpreting events + +Use `client.turn.requested` for active-use reports. `client.connected` counts +reconnects, so network behavior can inflate it. One identity can appear in several +client groups during a period; adding those groups double-counts users. + +Provider send and completion counts need not match. Providers can emit synthetic +turns without a send request. Collection is best effort, with no scan or backfill +of provider history. + +Token totals cover the main agent. Child agents and model rerouting prevent a turn +from representing one provider/model combination's full cost. For provider +comparisons, require complete usage, no observed subagents, and no mixed models; +compare matching model, effort, interaction mode, and terminal status. Aggregate +output/input ratios should divide the summed totals. Averaging per-turn ratios +lets small-input turns dominate. + +Unknown counts stay absent. Partial usage contains valid observed counts but +cannot establish a whole-turn total. Keep these distinctions when changing token +normalization or building reports. + +## Collection boundary + +Keep analytics payloads to product metadata and normalized measurements. Do not +send prompts, authentication material, raw provider payloads, user-assigned device +names, or conversation identifiers. Client metadata is best effort; invalid values +must not reject a connection. PostHog person profiles remain disabled. diff --git a/docs/internals/providers.md b/docs/internals/providers.md index bfd294532c74..30c1d00802cb 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -1,395 +1,88 @@ -# Provider architecture - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -A provider is the agent runtime that does the actual work. T3 Code supports several, and the -orchestration layer does not know which one is behind a thread. - -## Built-in drivers - -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: - -| Driver kind | Driver source | -| ------------- | --------------------------------------------- | -| `codex` | [`Drivers/CodexDriver.ts`][codex] | -| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | -| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | -| `grok` | [`Drivers/GrokDriver.ts`][grok] | -| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | -| `antigravity` | [`Drivers/AntigravityDriver.ts`][antigravity] | - -Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an -adapter in a child scope. Adapter implementations live beside them in -`apps/server/src/provider/Layers/` (`CodexAdapter.ts`, `ClaudeAdapter.ts`, and so on) and conform to -[`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's -transport, config, and event shapes are mapped. - -## Runtime context - -Every adapter uses `apps/server/src/provider/RuntimeInstructions.ts` to identify T3 Code and -the harness, and describe Markdown image/video embeds. Codex includes it in developer -instructions; Claude appends it to its system preset; OpenCode sends it in each prompt's -`system` field. Cursor, Grok, and Antigravity append a separate text block to ACP prompts, -which have no system-message field. This does not change the stored user message. - -Per-turn context includes the current model when known. Codex includes reasoning effort; -Grok includes it when explicitly selected for the turn. Claude's session-level context omits model and effort because they can -change during a session. OpenCode variants are not assumed to be reasoning-effort levels. - -## Codex async questions - -Codex 0.153 exposes `request_user_input_async` through `item/started` and `item/completed` -notifications. The item has `type: "agentMessage"`, `delivery: "async"`, and a `questions` array. -Each question has a `title` and an optional `options` array of strings. The tool returns `{"accepted":true}` -without waiting. This is separate from the `item/tool/requestUserInput` server request. -See the [Codex tool handler](https://github.com/openai/codex/blob/d979df154cf60e13eafb5453e75b6d84f21c67bf/codex-rs/core/src/tools/handlers/request_user_input_async.rs). - -The Codex adapter maps completed question items to `user-input.requested` with -`responseMode: "message"` and stable request and event IDs. Questions use the existing web, -desktop, and mobile panels. They stay pending while the turn runs and after it finishes. - -The engine reads the request's latest stored activity before deciding a reply. This works after -startup, when the command snapshot has no activities, and after a resolution leaves the recent -activity window. The query returns one activity, not the full thread history. - -For these requests, the decider saves the resolution and a user message in one transaction. -The standard turn path delivers the message, including session resume and active-turn input. -It does not send a JSON-RPC response to Codex. Other providers and blocking Codex questions -keep their existing response paths. - -## Registry and routing - -Two registries separate configuration from live processes: - -- [`ProviderInstanceRegistry`][instances] keys configured instances by `ProviderInstanceId`. Creating - one looks up the driver by `driverKind`, decodes `entry.config` with that driver's schema, opens a - child scope, and calls `driver.create`. -- [`ProviderAdapterRegistry`][registry] resolves an instance ID to its live adapter via - `getByInstance`. - -[`ProviderService`][service] sits on top. It combines the adapter registry with the provider session -directory to route session and turn operations for a thread, so callers name a thread, not an agent. - -`ProviderService.sendTurn` expands [assistant citations](./assistant-citations.md) into quoted -reference data before dispatching to any adapter. Bound user comments remain distinct from the quoted -assistant text. Persisted messages keep their serialized links. - -Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No -orchestration, contract, or client change is required for the common case. - -### Grok health check - -`checkGrokProviderStatus` never opens an ACP session. It runs `grok --version`, then `grok models` -for login state and model slugs, then a single ACP `initialize` and reads models from -`_meta.modelState`. `authenticate` and `session/new` are skipped on purpose: `authenticate` can open -a browser login and `session/new` boots every configured MCP server, both of which made background -probes hang or surprise the user. A failed `initialize` degrades to `warning` with the CLI's model -list instead of persisting `error` over a working install. The built-in `grok-build` slug is the -CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the -session's current model" and never sends it in `session/set_model`. - -## Antigravity ownership and protocol - -[`AntigravityDriver`][antigravity] uses Google's official ACP executable. The instance config -selects the ACP auth method: `oauth-personal` (default), `oauth-business`, `gemini-api-key`, or -`agent-platform`. The two OAuth methods share the loopback sign-in flow below. The API key -methods pass the configured key to the agent as `GEMINI_API_KEY` or `GOOGLE_API_KEY` and never -open a browser. A GCP project and location are written to the profile's `settings.json` on each -launch. The driver never reuses CLI credentials or ambient `GOOGLE_*` variables and never falls -back to another method. Antigravity is disabled by default and supports multiple provider -instances. The open driver and instance identifiers require no database migration. - -### Runtime installation - -[`AntigravityInstallation`][antigravity-installation] belongs to the environment, outside -WebSocket and provider-instance scopes. Instances share an explicit download operation and the -completed runtime. Client disconnects and instance rebuilds do not cancel installation. - -The fixed [release table][antigravity-release] contains official Google URLs, SHA-256 hashes, -archive sizes, and the exact executable pair for each published host. Downloads stream to disk. -Lazy `yauzl` entry streams extract only that pair, with member names, types, duplicates, and -sizes checked. Validation runs ACP `initialize` in a temporary profile without authentication -or a session. Progress updates are bounded, not sent for every network chunk. - -Complete releases live in immutable version directories under the T3 home -`tools/antigravity-acp/-/versions`. An atomic `active.json` change selects the -release for new processes. Each process holds a version lease until it exits. Updates do not -replace running executables. Removal refuses active leases or explicit binary paths that still -reference the managed files. Failure or cancellation removes owned partial files, not the -previous release or account data. - -Resolution order is explicit `binaryPath`, active managed release, then the instance's `PATH`. -An invalid explicit path fails without fallback. Manual installations are never changed by the -installer. Every launch pins `ANTIGRAVITY_HARNESS_PATH` to the selected executable's sibling. - -### Google profiles and sign-in - -Each instance owns a stable profile at -`/providers/antigravity/`. -[`antigravityAuthSupport.ts`][antigravity-auth-support] sets `GEMINI_HOME` to this directory and -`AGY_ACP_FORCE_FILE_STORAGE=1` after merging instance environment variables. File storage avoids -the official macOS keychain entry being shared across instances. Profile directories use mode -`0700` on POSIX. This is file storage, not an encrypted keychain. Windows uses the host profile's -filesystem permissions. - -The launch environment removes API-key and cloud-billing variables, disables inherited -environment extension, sets `PYTHONUNBUFFERED=1`, and controls `BROWSER`. A tested Node or -Electron-as-Node helper prevents the official agent from opening a browser on the environment. -The same launch factory serves setup, health checks, chat, and text generation. - -The official agent prints a non-JSON OAuth line on stderr in version 1.1.1. Earlier versions -print it on stdout. T3 accepts the exact native prefix on either stream and its browser-helper -marker on stderr. Fragmented lines are joined and bounded. Other malformed protocol output -remains fatal. Authorization URLs are validated before use. Other stderr is discarded because -it can contain OAuth data. Normal work rejects an interactive login request with a -sign-in-required error instead of waiting for consent. A rejected stderr callback fails pending -ACP requests and closes the owned process. - -[`AntigravityAuth`][antigravity-auth] owns each sign-in process and deadline in the instance -scope. Only the initiating T3 auth session receives its URL and flow ID or can complete or -cancel it. Other clients receive busy state without those values. Subscriptions follow -controller replacement when settings rebuild an instance. - -For remote completion, the client sends the full return URL through the typed setup RPC. -The server validates the pending loopback origin, port, root path, and single matching state -before forwarding once to the owned listener. It does not probe the listener or follow -redirects. Google's process owns PKCE, token exchange, refresh, and storage. Callback HTTP -success is not authentication success. The controller waits for authenticated session setup -and catalog discovery. Cancellation closes the process instead of sending a synthetic denial. - -Auth RPCs `provider.auth.start`, `complete`, `cancel`, `logout`, and `subscribe` require -`orchestration:operate`. Install `start`, `cancel`, and `remove` use that scope too. -`provider.install.subscribe` and public provider snapshots require `orchestration:read`. -[`providerSetup.ts`][provider-setup] defines the operation IDs, states, and safe errors. - -Sign-out closes process admission for the instance, stops provider bindings through -[`ProviderAuthService`][provider-auth-service], then stops owned startup and helper processes. -A fresh official process calls `initialize` and native `logout` without authenticating. -Only then does the provider clear auth, models, commands, skills, and workspace metadata. -Thread history and native session files remain. Settings sign-out and a text-only `/logout` -use this same path. The command is handled before model prompting or title generation. -Disabling an instance closes its processes but keeps credentials. Account replacement is -explicit sign-out followed by sign-in. - -### Sessions, models, and client capabilities - -[`AntigravityAdapter`][antigravity-adapter] owns one ACP process per active thread. It uses -native `session/resume` without transcript replay and reapplies the persisted model and -permission mode after new or resumed setup. An unavailable explicit model fails instead of -accepting the native default. Steering cancels the previous prompt, waits for its result and -event drain, then sends the replacement. Native background commands use T3's existing -background-task state. - -The permission mapping is `approval-required` and `auto` to `default`, `auto-accept-edits` to -`auto_edit`, and `full-access` to `yolo`. Native requests still need replies in `yolo`. -`interaction_` requests are user questions, not approvals. T3 keeps opaque option IDs in -`UserInputQuestion.options[].value` and sets `allowCustomAnswer=false`. Both clients preserve -these values. Ordinary approval replies use only offered option IDs, including `allow_always` -only when present. Existing providers keep their prior behavior when the optional fields are -absent. - -`showInteractionModeToggle=false` keeps native `/plan` separate from T3 Plan mode. -`supportsConversationRollback=false` hides unsupported client actions and makes checkpoint -revert fail before filesystem changes. Checkpoint capture and diffs remain supported. - -Automatic status refreshes, reconnects, and workspace checks do not open catalog sessions. -Health probes use `initialize` only. Disabled instances do not run background probes. -An explicit `serverRefreshProviders` request with `refreshModels: true` calls the driver's -optional `refreshModels` operation. Antigravity opens a short-lived catalog session under the -instance's process admission guard, uses saved credentials, publishes models and commands, -then closes the process. An interactive login request fails with sign-in required. Web's -**Refresh provider status** and mobile's **Refresh models** actions request this operation. -Account access starts unknown and becomes authenticated after successful session setup, -including an explicit model refresh. -The [provider snapshot][antigravity-provider] takes models and commands from setup and native -updates. It preserves returned Gemini model IDs, labels, order, and thinking-level choices. -ACP `config_option_update` notifications and `session/set_config_option` responses replace -the instance's model catalog. Child session notifications do not change the root catalog. -The registry treats a successful empty catalog as authoritative and clears cached metadata -after sign-out. It must not retain a previous account's models. Cached models do not prove -current access. The auth response does not supply an email, plan tier, or reliable quota. - -Some upstream failures arrive as assistant text followed by `end_turn`. Preserve that message -without treating model-written text as a structured error or successful task completion. - -### Text generation - -[`AntigravityTextGeneration`][antigravity-text] implements titles, branch names, commit text, -and PR text through the same instance and Google sign-in. Each helper uses a temporary empty -workspace, no injected MCP servers, native `default` mode, and explicit denial of tools and -questions. Output is bounded, parsed against the existing schemas, and sanitized. Cancellation, -timeout, and sign-out close the process. Cleanup removes only that helper's verified temporary -native session files. - -The official agent has no verified hard no-tools setting. Global hooks and MCP configuration -can run before a prompt. Helpers check the profile's `config/hooks.json` and -`config/mcp_config.json` before launch and reject nonempty, malformed, or oversized -configuration. `supportsTextGeneration=false` keeps such an instance out of system-model -pickers. Empty managed profiles are supported. Do not describe prompt-time denial as a native -sandbox. - -## OpenCode server ownership and catalog - -Native OpenCode event logs skip text deltas and running tool snapshots. Pending, completed, -and failed tool states remain, including final output and errors. This filter does not change -events sent to clients or orchestration storage. - -Each OpenCode provider instance owns one lazy local server for catalog discovery and -text-generation helpers through [`OpenCodeServerOwner.ts`][opencode-server-owner]. Concurrent -borrowers share startup. The server closes 30 seconds after the last borrower releases it, or -when the provider instance closes. A failed or exited process can be started again on the next -use. An externally configured OpenCode server remains externally owned. - -The local server and its SDK clients use one resolved password. An explicit provider password -overrides `OPENCODE_SERVER_PASSWORD` in the spawned environment. Without an explicit password, -the client uses the password from the environment that the process inherits. External servers use -only their explicit provider password and never inherit the host's local password. - -Every server connection must pass the authenticated `/global/health` check before inventory or -session operations start. The response must contain a valid version at or above 1.14.19. Local -owners cache this result for the lifetime of the spawned process. External actions check once when -they create their server connection, not for each model or SDK request. - -Chat adapters keep their own server per thread. They register a thread-specific `t3-code` MCP -connection, while OpenCode stores MCP connections by directory. Sharing these chat servers -without changing MCP routing would let two threads in one directory replace each other's -connection. - -Chat adapters send the runtime mode as a session ruleset, but upstream OpenCode evaluates -doom-loop and subagent asks against the agent ruleset only. In full access the adapter answers -those asks itself so the user never sees an approval they already granted. It replies `once` -rather than `always` because OpenCode stores `always` grants per directory, and on a shared -external server that would widen what a supervised thread in the same directory may do. - -OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The -provider registry keeps the snapshot in memory and persists it in the existing per-instance cache. -Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the -OpenCode catalog from the current helper. The `serverRefreshProviders` request also refreshes it. -Periodic OpenCode probes remain disabled. OpenCode reads credentials for each inventory request, -but its native configuration files can remain cached for the lifetime of the helper process. The -helper closes 30 seconds after its last inventory or text-generation borrower releases it. A -refresh after that idle period starts a new helper and reads file changes. Repeated refreshes and -active text-generation work can extend process reuse. Changes to the provider configuration or -environment replace the instance and start a new discovery. Changes to unrelated settings only -update snapshot enrichment. Other providers retain their existing refresh policy. - -T3 Code does not own an external OpenCode process. Native configuration changes there can require -an external reload or restart before T3 Code's next refresh sees them. - -The shared server's idle shutdown does not clear the catalog. Failed discovery keeps the last -known models, slash commands, and skills through the registry's existing merge rules. A successful -empty inventory is authoritative. Existing threads keep their explicit model identifier and -options when catalog metadata is missing; the catalog is not permission to choose a different -model for a thread. - -## Model manifest - -The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which -lists the current (non-legacy) model slugs per driver kind. The `ModelManifest` service -(`apps/server/src/provider/ModelManifest.ts`) refreshes that data from the same file on `main` via -raw.githubusercontent.com, so moving a model in or out of the legacy section is a commit, not a -release. Preference order is remote fetch, then the on-disk copy of the last successful fetch (in -the state directory), then the bundled copy. Fetches are TTL-gated, run concurrently with provider -probes, respect the `enableProviderUpdateChecks` setting, and never fail a provider check. The -Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`; -driver kinds absent from the manifest have no legacy concept. - -## Attachment access - -The server stores uploaded attachments in its attachment directory, outside the project workspace. -`ProviderService` adds the absolute path of each attachment to the turn text, then passes every -attachment to the provider adapter. Each adapter decides what its provider ingests natively: - -- Codex, Claude, Cursor, and Grok send images as native image inputs and skip generic files. For - these providers, generic files reach the agent only as file paths in the turn text. -- Antigravity sends BMP, JPEG, PNG, and WebP images and common audio formats as native blocks, - text files as embedded resources, and PDFs as resource links. Other files are rejected with an - error instead of being dropped. The session advertises the ACP client file system capability, - so workspace reads and writes come back through `fs/read_text_file` and `fs/write_text_file` - and are confined to the workspace and the attachments directory. -- OpenCode sends PNG/JPEG/GIF/WebP images, text files, and PDFs up to 20 MB as native file parts - with their real mime type. Everything else (ZIP and other binaries, image formats model APIs - reject, oversized files) falls back to the file path in the turn text, like the other providers. -- Antigravity sends BMP/JPEG/PNG/WebP images as native image blocks, UTF-8 text as embedded - resources, and PDFs as local resource links. Text is limited to 1 MiB per file, images to - 10 MiB each, and all attachments to 50 MiB per turn. Unsupported formats or oversized inputs - fail explicitly. Native path permissions still apply to PDFs. - -Claude receives the attachment directory as an allowed additional directory. Codex keeps its -configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode -allows all paths in full-access mode and requests approval for directories outside the workspace in -restricted modes. Cursor and Grok use their own provider permission rules. - -The server does not copy attachments into a project or bypass provider approval rules. If an agent -cannot read an attachment, the user must approve the access or select a runtime mode that permits it. - -Updated attachment schemas tolerate unknown attachment members, but old image-only clients still -cannot decode messages that contain file attachments. Client file-picking rollouts must account for -this limit. - -Do not run an old image-only server against state that contains file attachments. Replay decodes -each persisted event before projection. A file-bearing event can make `ProjectionPipeline` bootstrap -and `OrchestrationEngine` startup fail for the entire environment, not only the affected thread. - -## How provider work is requested - -Clients never call a provider directly. They dispatch orchestration commands over the RPC method -`orchestration.dispatchCommand`, defined with the rest of the orchestration surface in -[`orchestration.ts`][contracts]. The client-dispatchable provider-facing commands are -`thread.turn.start`, `thread.turn.interrupt`, `thread.approval.respond`, -`thread.user-input.respond`, `thread.checkpoint.revert`, and `thread.session.stop`, plus the mode -setters `thread.runtime-mode.set` and `thread.interaction-mode.set`. - -The engine persists an event for the command, and a server-side reactor performs the provider call. -Provider output comes back as internal commands such as `thread.message.assistant.delta` and -`thread.session.set`, which clients observe through `orchestration.subscribeThread`. See -[overview.md](./overview.md) for the command/event loop. - -## Server-side workers - -Provider work flows through three queue-backed workers. All three are built with -`makeDrainableWorker` from [`DrainableWorker.ts`][worker] and expose `drain` for deterministic test -synchronization. - -1. [`ProviderRuntimeIngestion`][ingest] consumes provider runtime streams and emits orchestration - commands. -2. [`ProviderCommandReactor`][cmd] reacts to orchestration intent events and dispatches provider - calls. -3. [`CheckpointReactor`][checkpoint] captures workspace checkpoints on turn start and completion, and - performs reverts. - -### Buffered assistant delivery - -A thread in `buffered` assistant delivery mode accumulates assistant text instead of streaming each -delta. The buffer is not held until turn completion. In [`ProviderRuntimeIngestion`][ingest], -`MAX_BUFFERED_ASSISTANT_CHARS` is 24,000: the append that would exceed it invalidates the buffer and -spills the whole accumulated text as one delta. The buffer also flushes at interaction boundaries, -when a request opens (approval) or user input is requested, via -`flushBufferedAssistantMessagesForTurn`. - -[drivers]: ../../apps/server/src/provider/builtInDrivers.ts -[codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts -[claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts -[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts -[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts -[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts -[antigravity]: ../../apps/server/src/provider/Drivers/AntigravityDriver.ts -[antigravity-adapter]: ../../apps/server/src/provider/Layers/AntigravityAdapter.ts -[antigravity-provider]: ../../apps/server/src/provider/Layers/AntigravityProvider.ts -[antigravity-installation]: ../../apps/server/src/provider/AntigravityInstallation.ts -[antigravity-release]: ../../apps/server/src/provider/antigravityRelease.ts -[antigravity-auth]: ../../apps/server/src/provider/AntigravityAuth.ts -[antigravity-auth-support]: ../../apps/server/src/provider/antigravityAuthSupport.ts -[antigravity-text]: ../../apps/server/src/textGeneration/AntigravityTextGeneration.ts -[provider-auth-service]: ../../apps/server/src/provider/Layers/ProviderAuthService.ts -[provider-setup]: ../../packages/contracts/src/providerSetup.ts -[opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts -[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts -[instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts -[registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts -[service]: ../../apps/server/src/provider/Layers/ProviderService.ts -[contracts]: ../../packages/contracts/src/orchestration.ts -[worker]: ../../packages/shared/src/DrainableWorker.ts -[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts -[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts -[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts +# Provider constraints + +Orchestration records intent and state without knowing which provider runs a thread. Provider +protocols, account ownership, permissions, and capabilities belong at the +[adapter boundary](../../apps/server/src/provider/Services/ProviderAdapter.ts). Normalize there +instead of spreading provider checks through reactors and clients. + +A driver kind identifies an integration; an instance identifies one configuration and account +lifecycle. Route work by instance, so two accounts using the same driver do not share mutable +session or catalog state. + +## Process and account isolation + +T3-managed OpenCode chat uses one server per thread. Its MCP registrations are directory-scoped, while +T3's MCP connection is thread-scoped. Sharing a chat server between threads in one directory would +let them replace each other's connection. Catalog and text-generation work can share the +[instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes +after an idle period. External OpenCode servers remain externally owned and can require an +external restart to pick up configuration changes. + +OpenCode also stores persistent approval grants per directory. Automatic full-access replies use +`once` so they cannot widen a supervised thread's permissions on a shared external server. +See the [adapter](../../apps/server/src/provider/Layers/OpenCodeAdapter.ts). + +Antigravity separates account profiles per instance while sharing installed executables across the +environment. It forces file-based credential storage because the native macOS keychain entry would +otherwise be shared across instances. The launch environment removes ambient Google credentials, +so an instance cannot silently use another account or billing project. +See [profile isolation](../../apps/server/src/provider/antigravityAuthSupport.ts). + +The [Antigravity installer](../../apps/server/src/provider/AntigravityInstallation.ts) outlives +client connections and provider-instance rebuilds. Releases are immutable, with an atomic pointer +selecting the version for new processes. Running processes hold leases on their version. Updates +and removal must respect those leases instead of replacing executables under a running agent. + +## Setup must not happen as a health-check side effect + +Opening a provider session can start MCP servers, run hooks, or launch a login browser. +[Grok probes](../../apps/server/src/provider/Layers/GrokProvider.ts) avoid authentication and +session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for +explicit setup or model refresh; background checks use initialization only. + +[Antigravity sign-in](../../apps/server/src/provider/AntigravityAuth.ts) belongs to the initiating +T3 auth session. The client carries the return URL back to the environment because the provider's +loopback listener may be on another machine. Forward only the callback for the owned pending flow; +a successful callback HTTP request is not proof that provider authentication finished. The native +process owns token exchange and storage. + +Antigravity sign-out closes admission to new processes and stops existing processes before clearing account +metadata. Otherwise a helper or resumed session could retain the old account. Cached model lists +do not establish current access, and an authoritative empty catalog must clear the old list. + +Antigravity text-generation helpers deny tool requests, but native hooks and MCP configuration can +run before the prompt. They reject profiles with such configuration before launch. Prompt +instructions and tool denial do not create a native sandbox. +See [helper constraints](../../apps/server/src/textGeneration/AntigravityTextGeneration.ts). + +## Protocol traps + +Codex async questions arrive as notifications and are answered with a new user message. There is +no pending RPC response to send. Blocking questions still use the request/response path. The +[adapter](../../apps/server/src/provider/Layers/CodexAdapter.ts) distinguishes them; the +[decider](../../apps/server/src/orchestration/decider.ts) records an async answer and its user +message together. + +An async question can outlive the turn or a server restart. The engine reads that request's +durable activity before resolving it because the in-memory command snapshot omits old activities. +Do not infer that a request has disappeared merely because it is outside the recent window. + +Capabilities must describe what the provider can actually do. Antigravity can capture workspace +checkpoints but cannot roll back its conversation. The [checkpoint boundary](./overview.md#turn-completion-and-checkpoints) +therefore rejects revert before touching files. Native permission and question option IDs must +also survive normalization; a display label is not necessarily a valid reply. + +## Attachments and stored history + +Attachments live outside the project workspace. [ProviderService](../../apps/server/src/provider/Layers/ProviderService.ts) +puts their environment-local paths in turn input and lets adapters choose native input formats. +A path in the prompt does not grant filesystem access. Keep provider sandbox and approval rules +in force; copying uploads into the project to bypass them changes that boundary. + +File attachments introduced a replay compatibility limit. Image-only clients cannot decode +file-bearing messages, and an image-only server can fail the entire environment's startup when +replaying one such event. Rollouts and downgrades must account for persisted history as well as +current client support. + +Model classification has its own [manifest constraints](./model-manifest.md). Assistant-reference +handling is documented under [citations](./assistant-citations.md). diff --git a/docs/internals/remote.md b/docs/internals/remote.md index d27a0741c2ad..2faf1e930eef 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -1,256 +1,59 @@ -# Remote Architecture - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -Remote environments are shipped, not planned. Direct, bearer-paired, relay-tunneled, Tailscale, and -desktop-managed SSH access all exist today. This document describes the model they share and where -each piece lives. For the user-facing setup guide see -[remote access](../user/remote-access.md). - -## The model - -T3 has one runtime boundary: a client talks to a T3 server over HTTP and WebSocket, and the server -owns orchestration, providers, terminals, git, and filesystem operations. Remoteness is expressed at -the connection layer, never by splitting the runtime. - -```text -┌──────────────────────────────────────────────┐ -│ Client (desktop / mobile / web) │ -│ known environments, connection supervisor │ -└───────────────┬──────────────────────────────┘ - │ resolves one access endpoint -┌───────────────▼──────────────────────────────┐ -│ Access method │ -│ direct ws/wss, relay tunnel, │ -│ Tailscale serve, desktop-managed ssh │ -└───────────────┬──────────────────────────────┘ - │ connects to one T3 server -┌───────────────▼──────────────────────────────┐ -│ Execution environment = one T3 server │ -│ identity, providers, projects/threads, │ -│ terminals, git, filesystem │ -└──────────────────────────────────────────────┘ -``` - -### ExecutionEnvironment - -One running T3 server instance. It owns provider availability and auth, model availability, projects -and threads, terminal processes, filesystem access, git operations, and server settings. - -It is identified by a stable `environmentId`, persisted by the server at `/environment-id` -and generated on first start (`apps/server/src/environment/ServerEnvironment.ts`). Desktop, mobile, -and web all reason about the same concept. - -Initialization publishes a complete ID atomically. Empty or whitespace-only ID files are repaired -using a retained `/environment-id.recovery` file so concurrent and delayed repairs choose -the same ID. Existing nonempty ID files remain authoritative. - -### Known environments and connection targets - -A saved client-side entry for an environment the client knows how to reach. It is not -server-authored; it is local to a device or client profile. In the hosted web app these entries are -browser-local. A hosted pairing URL can create one, but it does not give the hosted app a server-side -control plane or a copy of session state. - -[`connection/model.ts`][model] defines four target tags, which are the real access taxonomy: - -| Target | Used for | -| ------------------------- | ------------------------------------------------------------------------ | -| `PrimaryConnectionTarget` | The platform-managed local server (desktop backend, CLI-served web app). | -| `BearerConnectionTarget` | Any manually paired endpoint reached over direct HTTP/WebSocket. | -| `RelayConnectionTarget` | Managed T3 Connect relay tunnels. | -| `SshConnectionTarget` | Desktop-managed SSH environments. | - -Bearer, relay, and SSH are persisted; primary is platform-managed. Note that Tailscale is not a -separate target kind. A Tailscale URL is paired through the ordinary bearer path in -[`onboarding.ts`][onboarding] (`preparePairingRegistration`), which accepts either a pairing URL or a -host plus pairing code. Tailscale is an endpoint provider and transport, not a distinct runtime -concept. - -### AdvertisedEndpoint - -A server- or desktop-authored candidate endpoint for an environment: a concrete HTTP and WebSocket -base URL pair, a default/available/unavailable marker, reachability hints (loopback, LAN, private, -public, tunnel), and compatibility hints such as whether the hosted HTTPS app can use it. - -Clients treat advertised endpoints as hints, not proof that a route works from the current device. -The connection attempt decides. - -The UI shows one default endpoint in the network-access summary and keeps the rest behind an advanced -list. `selectPairingEndpoint` in -[`ConnectionsSettings.tsx`](../../apps/web/src/components/settings/ConnectionsSettings.tsx) excludes -unavailable endpoints and then picks, in order: - -1. the saved `defaultEndpointKey` override; -2. the first endpoint marked `isDefault`; -3. the first endpoint whose reachability is not `loopback`; -4. the first endpoint compatible with the hosted HTTPS app; -5. otherwise nothing. - -There is no unconditional loopback fallback. A loopback endpoint only wins through an explicit saved -override or `isDefault`. Persist the override by stable endpoint kind rather than raw URL where -possible, since LAN addresses change with networks; Tailscale endpoints use provider-specific stable -keys (`tailscale-ip:`, `tailscale-magicdns:`). - -### Endpoint providers - -Endpoint providers contribute advertised endpoints without becoming part of the core environment -model: core owns environments, pairing, and connection lifecycle, and providers return normalized -`AdvertisedEndpoint` records. - -Tailscale is the first provider, and T3 manages more than discovery. When `tailscaleServeEnabled` is -set, the server acquires a Tailscale serve mapping for its actual listening port at startup with -`ensureTailscaleServe` and releases it with `disableTailscaleServe` on scope close -(`apps/server/src/server.ts`, using [`@t3tools/tailscale`](../../packages/tailscale/src/tailscale.ts)). -Endpoint identifiers are synthesized in `apps/desktop/src/backend/tailscaleEndpointProvider.ts` with -`private-network` reachability. - -### Hosted pairing request - -A hosted pairing request is a bootstrap URL for the static web app, not a transport: - -```text -https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE -``` - -The hosted app reads `host`, takes the token from the URL hash, exchanges it directly with that -backend, strips the token from browser history, and saves the environment record locally. Helpers -live in [`shared/remote.ts`](../../packages/shared/src/remote.ts) (`setPairingTokenOnUrl`, -`getPairingTokenFromUrl`, `stripPairingTokenFromUrl`) and `apps/web/src/hostedPairing.ts`. - -Constraints: - -- the hosted app does not proxy HTTP or WebSocket traffic; -- the backend must be directly reachable from the browser; -- HTTPS pages can only reach HTTPS/WSS backends; -- HTTP LAN endpoints keep using direct desktop or CLI pairing URLs; -- the token belongs in the hash so it is never sent to the hosted app origin. - -### RepositoryIdentity and Project - -`RepositoryIdentity` is a best-effort logical repo grouping across environments, used for UI grouping -and correlation only, never for routing. `Project` remains environment-local: a local clone and a -remote clone are different projects that may share a `RepositoryIdentity`, and threads bind to one -project in one environment. - -## Access methods - -Access answers one question: how does the client speak WebSocket to a T3 server? It does not answer -how the server got started or who manages the process. - -### Direct WebSocket access - -`wss://t3.example.com` or `ws://10.0.0.15:3773`, paired as a bearer target. This is the base model. -It works for desktop, mobile, and web with no client-side process management. Browser security rules -are part of it: a hosted HTTPS client cannot connect to plain `ws://` or `http://` LAN backends. - -### Relay-tunneled access - -Managed T3 Connect relay tunnels use `RelayConnectionTarget` and are the answer when the host is -behind NAT, inbound ports are unavailable, or mobile must reach a desktop-hosted environment. From -the client's perspective this is still an ordinary WebSocket connection; the route is mediated. The -relay Worker only brokers credentials and a managed endpoint; application traffic then flows over -the provisioned Cloudflare tunnel hostname for the life of the connection, not through the relay -Worker itself. See [t3-connect.md](./t3-connect.md). - -### Tailscale access - -A T3-managed `tailscale serve` mapping exposes the server on the tailnet over HTTPS, and the -resulting private-network endpoints are advertised for pairing. Connection then follows the ordinary -bearer path. - -### Desktop-managed SSH access - -SSH is an access and launch helper, not a separate environment type. `DesktopSshEnvironment` -([apps/desktop/src/ssh/DesktopSshEnvironment.ts][sshenv]) exposes `discoverHosts`, -`ensureEnvironment`, and `disconnectEnvironment`. It discovers targets from SSH config and known -hosts, owns password/askpass prompts, and delegates lifecycle to `SshEnvironmentManager` in -[packages/ssh/src/tunnel.ts][sshtunnel], which resolves the target, launches or reuses the remote T3 -server, opens a local tunnel, checks HTTP readiness, optionally issues a remote pairing token, and -returns local HTTP/WS endpoints. Disconnect closes the tunnel and stops the remote server if the -launcher started it; a server that was already running (marked `external`) is left running. - -The desktop main process owns this because it can spawn SSH, manage prompts, write launch scripts, -and clean up forwards. The renderer connects through the forwarded URL like any other environment and -needs no SSH-specific RPC path. - -Failure handling is explicit: SSH auth failure surfaces before an environment is saved, remote launch -failure includes launcher output where available, forwarded-port failure leaves the environment -disconnected rather than falling back to an unrelated endpoint, and reconnect restores the SSH bridge -before reconnecting the WebSocket client. - -## Launch methods - -Launch answers a different question: how does a T3 server come to exist on the target machine? Keep -it separate from access. - -- **Pre-existing server.** The operator already runs T3 and the client connects directly or through a - tunnel. -- **Desktop-managed remote launch over SSH.** Desktop probes the machine, launches or reuses a remote - server, forwards a port, and the renderer connects normally. The saved environment records that it - came from SSH launch for reconnect and lifecycle UX only; that metadata never changes the protocol - or the identity model. -- **Client-managed local publish.** A local server is published through the relay with - `t3 connect link`, exposing a desktop-hosted environment to mobile without router or firewall - changes. - -The same `ExecutionEnvironment` can be reached several of these ways. Only the launch and access -paths differ. - -## Security model - -Some environments are reachable over untrusted networks, so remote-capable environments require -explicit authentication, tunnel exposure never relies on obscurity, and saved endpoints carry enough -auth metadata to reconnect safely. - -WebSocket authentication is a dedicated short-lived ticket, not a token in a query string. The client -presents its long-lived bearer or DPoP credential in HTTP headers to -`POST /api/auth/websocket-ticket` ([authorization/remote.ts][authremote]), and appends only the -returned ticket as `wsTicket` on the socket URL. The server issues it through -`EnvironmentAuth.issueWebSocketTicket`; tickets are tagged `kind: "websocket"` and default to a -five-minute TTL (`DEFAULT_WEBSOCKET_TOKEN_TTL` in `apps/server/src/auth/SessionStore.ts`). The -handshake verifies the ticket, and each RPC method still enforces its own scope. See -[environment-auth.md](./environment-auth.md). - -Hosted pairing is a client-side convenience only. The hosted app must not receive pairing tokens -through query parameters, must not store pairing state server-side, and must not imply that an HTTP -backend is reachable from an HTTPS browser context. - -## Version coordination - -Remote environments stay online while clients move to newer releases. The environment descriptor -carries the running server version and may advertise a safe replacement path, so the UI can show the -right action without making the transport responsible for process management. The connection -supervisor owns the resulting disconnect and reconnect like any other involuntary close. See -[server-updates.md](./server-updates.md). - -## Machine identity - -Every environment is drawn with one of a fixed set of machine glyphs (`EnvironmentMachineKind` in -contracts: server, cloud, desktop, laptop, mac-mini, mac-studio). Two inputs feed it, and the -precedence lives in one helper, `resolveEnvironmentMachineKind`, so web and mobile cannot drift: - -1. `settings.environmentIcon`, a nullable server setting the user picks in Settings → Connections. - It is server state rather than client state so every device connecting to that server agrees. -2. `environment.platform.machine`, detected once at startup by [ServerEnvironmentMachine.ts][machine] - (IOKit product name / `hw.model` on macOS, `/sys/class/dmi/id` on Linux with virtualization - markers mapped to "cloud"). Absent when there is no signal, so older servers, Windows hosts, and - containers decode without it. -3. A generic server otherwise. - -Clients treat the field like any other capability: absent means "use the fallback", never "wait". - -## Future work - -These remain unbuilt and are listed to keep the model honest: - -- third-party tunnel products as additional endpoint providers; -- a relay-hosted OAuth callback broker (see [t3-connect.md](./t3-connect.md)); -- richer multi-environment UI beyond the current connections list. - -[model]: ../../packages/client-runtime/src/connection/model.ts -[onboarding]: ../../packages/client-runtime/src/connection/onboarding.ts -[authremote]: ../../packages/client-runtime/src/authorization/remote.ts -[sshenv]: ../../apps/desktop/src/ssh/DesktopSshEnvironment.ts -[sshtunnel]: ../../packages/ssh/src/tunnel.ts -[machine]: ../../apps/server/src/environment/ServerEnvironmentMachine.ts +# Remote architecture + +Each connection joins a client to one environment over HTTP and WebSocket. The +environment owns providers, execution, files, and durable state. Direct access, +Tailscale, SSH, and T3 Connect change how the client reaches that server; they do +not introduce another execution model. See +[remote access](../user/remote-access.md) for setup. + +## Identity is independent of the route + +An environment keeps its ID across server restarts and endpoint changes. Saved +connections are local to a client profile; the server's identity and state are +not. A repository identity can correlate clones across environments, but never +routes work between them. A project and its threads belong to one environment. + +[Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts) +must publish a complete ID atomically. Repair of an empty ID file retains a +recovery file so concurrent or delayed initializers choose the same winner. +Removing that recovery state as ordinary temporary-file cleanup can change the +identity underneath an already-running server. + +Advertised endpoints are reachability hints. Only the connecting device can +prove that a route works. In particular, a host's loopback address refers to a +different machine when another device opens it. Endpoint selection must not +silently fall back to loopback when a shareable endpoint is unavailable. + +## Hosted web is a client + +The hosted web app stores its connection catalog in the browser and connects +directly to each environment. It does not proxy traffic or hold server-side +pairing state. Hosting the UI over HTTPS therefore cannot make a plain HTTP LAN +backend accessible from that browser context. + +A [hosted pairing URL](../../apps/web/src/hostedPairing.ts) identifies the backend +in its query and carries the pairing secret in its fragment. Fragments stay out +of requests to the hosted origin. The browser exchanges the secret with the +environment and strips it from its history. Moving the token into a query +parameter would disclose it to the wrong origin. + +## Access and process ownership are different + +Tailscale supplies an endpoint for ordinary pairing, so it needs no separate +environment type. Authentication remains the environment's responsibility for +every route. See [environment authentication](./environment-auth.md) and the +[T3 Connect trust boundary](./t3-connect.md). + +SSH can launch a server as well as forward a port. Desktop main owns that +lifecycle because it can spawn SSH and handle authentication prompts. The +renderer uses the forwarded endpoint through the shared connection runtime. +[SSH cleanup](../../packages/ssh/src/tunnel.ts) stops a remote server only if the +launcher owns it; a server it discovered already running must survive a client +disconnect. Reconnection restores the forward before opening the application +transport. + +Remote servers can outlive several client releases. Clients must use advertised +capabilities and handle their absence, rather than assume their own version +describes the server. Process replacement belongs to the launcher's +[update protocol](./server-updates.md); the connection runtime handles the +resulting disconnect. diff --git a/docs/internals/resource-telemetry.md b/docs/internals/resource-telemetry.md index 0f3e6674ff79..3ce3a6071948 100644 --- a/docs/internals/resource-telemetry.md +++ b/docs/internals/resource-telemetry.md @@ -1,394 +1,50 @@ -# Resource telemetry architecture - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -Status: implemented - -## Purpose - -Resource telemetry replaces recurring `ps`, PowerShell, `ioreg`, and `pmset` -subprocess probes with two persistent, direct data sources: - -1. a standalone Rust resource-monitor executable that reads process counters - through operating-system APIs via `sysinfo`; -2. Electron main-process APIs for Electron process metrics and host power state. - -The native monitor owns bounded in-memory history. The server only merges and -summarizes that history when diagnostics requests it. Telemetry history is not -persisted to disk or continuously copied into Node. - -## Why a standalone executable - -The monitor is intentionally not a Node native addon. - -- No N-API, `ffi-rs`, or dynamic-library ABI is loaded into the server process. -- A monitor crash cannot corrupt the Node runtime. -- The server can supervise, restart, version-check, and measure the monitor as a - normal child process. -- The same protocol works for the desktop app and the published CLI. -- Packaging is a single platform executable instead of an addon toolchain plus - Node/Electron ABI matrix. - -The cost is one persistent child process and NDJSON serialization. That is a -better failure boundary than repeatedly spawning shell utilities or loading -native code into Node. - -## Runtime topology - -### Desktop - -```text -Electron main - ├─ powerMonitor - ├─ app.getAppMetrics() while diagnostics is open - ├─ inherited fd 4, telemetry NDJSON ─────────────┐ - └─ inherited fd 5, demand-control NDJSON ◀──────┤ - ▼ -Node server ── stdin/stdout NDJSON ── Rust resource monitor - │ - ├─ ResourceTelemetry Effect service - ├─ background power policy projection - └─ WebSocket RPC/subscription ── diagnostics UI -``` - -### Web, headless, and remote server - -Electron telemetry is unavailable. The native monitor still runs beside the -server and tracks the server process tree. Power fields degrade to `unknown` -instead of invoking platform shell commands. - -### WSL backend limitation - -Windows desktop packages currently ship the Windows resource-monitor executable. -That executable cannot run inside the Linux WSL backend, so a WSL-only backend -does not receive `resourceMonitorPath` and reports native process telemetry as -unavailable. Electron host-power telemetry remains available over the inherited -desktop pipe. Supporting native WSL process telemetry requires publishing a -Linux sidecar for each supported architecture in the Windows artifact and -converting its packaged path into the selected distro; the configuration -deliberately does not pass the Windows `.exe` into WSL as a false fallback. - -## Native monitor - -The executable lives in `native/resource-monitor`. - -It receives schema-compatible commands on stdin and emits one JSON object per -line on stdout: - -- `configure` -- `setExternalProcesses` -- `setSampleInterval` -- `setStreaming` -- `sampleNow` -- `readHistory` -- `shutdown` -- `hello` -- `snapshot` -- `historyChunk` -- `error` - -The protocol version is defined by -`RESOURCE_MONITOR_PROTOCOL_VERSION` in -`packages/contracts/src/resourceTelemetry.ts`. - -### Collection - -The monitor keeps one `sysinfo::System` instance and refreshes it at the -power-adaptive interval selected by the server. It collects: - -- PID and parent PID; -- process start time and run time; -- process name and command line; -- current and cumulative CPU usage; -- resident and virtual memory; -- cumulative process I/O counters. - -On Linux, task/thread enumeration is disabled. Command lines are refreshed with -each sample so process replacements remain visible. Disabling task enumeration -avoids walking every `/proc//task/` directory on each refresh. - -### Process-tree selection - -Each sample scans the accessible process table, builds the PID/PPID graph, and -retains: - -- the server process; -- every descendant of the server, including provider-spawned grandchildren such - as shells, `node`, `tsgo`, language servers, and other tools; -- Electron processes supplied as explicit external roots; -- descendants of those Electron roots; -- the resource monitor itself, because it is a server child. - -Process identity is `(pid, startTimeMs)`, not PID alone. Electron and native -start times are matched with a two-second tolerance because native start times -can have coarser platform resolution. - -The process list is emitted in depth-first tree order so renderer collapse and -expansion preserves complete subtrees. - -### Native history and streaming - -Every native sample is appended to a one-hour in-memory ring bounded to 3,600 -snapshots, 20,000 retained process rows, and 64 MiB of retained history bytes. -History stays in the sidecar until a `readHistory` request and is returned in -bounded chunks. The first bound reached wins, so high process counts or large -process names and command lines shorten the effective history window. - -Periodic snapshot streaming is disabled by default. The server enables it only -while at least one diagnostics subscription is retained. `sampleNow` remains -available for explicit refreshes and identity validation. - -The server adjusts native sampling without restarting the sidecar: - -- suspended, locked, low-power, or serious/critical thermal state: 15 seconds; -- battery: 5 seconds; -- normal AC: 5 seconds in the background and 1 second while live diagnostics is - open; -- unknown or stale power: 5 seconds in the background and 1 second while live - diagnostics is open. - -### Sampling limits - -This is counter sampling, not syscall tracing. - -- A process that starts and exits entirely between samples may not be observed. -- Cumulative CPU and I/O counters still provide accurate deltas for processes - that survive across samples. -- Exact file paths, individual write syscalls, ETW events, eBPF events, and - Endpoint Security events are outside this implementation. - -Those deeper tracing systems can be added later as opt-in diagnostic modes -without changing the public `ResourceTelemetry` model. - -## I/O semantics - -The monitor preserves platform semantics instead of presenting all counters as -equivalent: - -- Unix-like platforms report storage I/O counters exposed by `sysinfo`. -- Windows reports all process I/O bytes, not only disk bytes. -- Operating-system caches can prevent logical application reads or writes from - appearing as physical storage bytes. - -The UI therefore labels these values as I/O reads and writes and exposes the -per-process `ioSemantics` value. - -Group totals are observed deltas since telemetry startup. Per-process total -columns are the operating system's cumulative counters for that process. - -## Electron telemetry - -Electron main owns `DesktopTelemetryPublisher`. - -Power events trigger an immediate snapshot. While diagnostics is closed, the -server sends the configured active and idle host-power intervals to Electron -(30 seconds and 2 minutes in the balanced profile). During those heartbeats -Electron reads: - -- `powerMonitor.isOnBatteryPower()`; -- `powerMonitor.getSystemIdleTime()`; -- `powerMonitor.getSystemIdleState()`; -- `powerMonitor.getCurrentThermalState()`. - -`app.getAppMetrics()` is only called while diagnostics demand is active. Its -live cadence is 1 second on AC, 5 seconds on battery, and 15 seconds while -locked, suspended, or thermally constrained. - -It also listens for: - -- lock and unlock; -- suspend and resume; -- AC and battery transitions; -- thermal-state changes; -- CPU speed-limit changes. - -Suspension stays latched across event-driven snapshots. An explicit resume -clears it immediately; a periodic heartbeat also clears a stale latch so a -missed resume event cannot leave telemetry permanently constrained. - -Electron does not expose a cross-platform low-power-mode getter, so that field -remains `unknown`. - -The desktop backend is spawned with: - -- fd 3 for the existing bootstrap payload; -- fd 4 for Electron-to-server telemetry NDJSON; -- fd 5 for server-to-Electron diagnostics-demand NDJSON. - -These are private Electron-main/server pipes. They do not use the renderer -WebSocket and are recreated for every backend restart. - -## Server Effect services - -The implementation is under `apps/server/src/resourceTelemetry`. - -### `ResourceMonitorBinary` - -Resolves an executable from: - -1. `T3CODE_RESOURCE_MONITOR_PATH`; -2. desktop bootstrap configuration; -3. bundled CLI resources; -4. local Cargo build outputs. - -Unsupported platforms, missing binaries, and non-executable binaries use -schema-backed tagged errors with descriptive messages. - -### `NativeTelemetryClient` - -Owns the resource-monitor process and protocol. - -- validates the hello/version handshake; -- sends configuration and external process roots; -- adapts the native interval from host power state; -- enables streaming only for scoped live subscribers; -- reads chunked native history on demand; -- exposes `sampleNow`; -- serializes commands; -- supervises process exit and protocol failure; -- restarts with bounded exponential backoff; -- opens a circuit after repeated failures; -- supports explicit retry; -- publishes health changes immediately. - -Snapshot sequence numbers are scoped to a monitor generation. Server ingestion -uses the monitor restart count as the generation key, so sequence reset after a -restart cannot freeze telemetry. - -### `DesktopTelemetryReceiver` - -Reads fd 4, decodes schema-validated messages, stores the latest Electron -snapshot, and publishes desktop health. It writes diagnostics demand to fd 5 -and gives the first sample a 90-second startup deadline. Once samples arrive, -the stale deadline stays beyond the slower configured host-power interval with -30 seconds of scheduling grace, so intentional 2–10 minute idle polling does -not oscillate the policy between constrained and unconstrained states. Decode -errors, protocol mismatch, control-write failure, stream failure, stale input, -and normal stream closure are represented explicitly. - -### `ResourceTelemetry` - -Merges native and Electron data and owns public telemetry semantics. - -- calculates CPU and I/O rates from cumulative native counters; -- preserves the last native rates during desktop-only updates; -- classifies backend, Electron, and monitor processes; -- computes process depth and child relationships; -- tracks starts, exits, CPU time, and observed I/O; -- projects power data; -- acquires native streaming and Electron process metrics only for scoped live - subscribers; -- queries and replays native history only when requested; -- validates `(pid, startTimeMs)` before process signaling; -- updates history health even when no further native sample arrives. - -Electron and monitor processes are visible but are not valid targets for the -existing process-signal RPC. - -### History projection - -`ResourceTelemetryHistory` is a pure on-demand projection. It replays raw native -snapshots to derive rates, lifecycle counters, buckets, and process summaries. -Current Electron process metrics are intentionally excluded from historical -replay so they cannot overwrite older native CPU or memory samples. - -### `ResourceAttribution` - -Tracks known logical application I/O separately from OS counters. Current -integration points record successful writes for: - -- provider native and canonical event logs; -- the local server trace sink. - -Entries contain component, operation, logical bytes, count, and elapsed time. -Future persistence paths should call `ResourceAttribution.record` rather than -adding diagnostics-specific counters. - -## Background policy integration - -`HostPowerMonitor` consumes `DesktopTelemetryReceiver` directly; observing host -power does not retain live resource diagnostics or invoke shell probes. - -The monitor updates its latest timestamp on every Electron sample but only -publishes semantic state changes. Increasing idle seconds alone does not cause a -background-policy broadcast every second. - -## Public API and UI - -The WebSocket RPC surface provides: - -- current snapshot; -- bounded history; -- explicit monitor retry; -- a live snapshot subscription. - -The diagnostics page displays: - -- aggregate CPU, memory, I/O, and process counts; -- backend, Electron, and monitor overhead groups; -- power and thermal state; -- collector health and restart information; -- CPU and I/O history; -- a collapsible live process tree; -- safe process signaling for backend descendants; -- instrumented logical application I/O. - -Legacy process diagnostics RPCs are projected from the same service so they no -longer start recurring process-table commands. - -## Packaging - -Desktop artifact builds compile the Rust target, stage it as -`resources/resource-monitor/t3-resource-monitor[.exe]`, and pass its path to the -backend bootstrap. - -CLI release jobs upload each active platform monitor artifact and copy it into: - -```text -apps/server/dist/resource-monitor/-/ -``` - -The published server package already includes `dist`, so those executables ship -with the CLI. Missing platform artifacts degrade native telemetry to -`unavailable`; the server continues running. - -## Resource and failure behavior - -Steady state uses: - -- one native process; -- power-adaptive native counter sampling with no periodic Node snapshot stream; -- event-driven Electron power updates plus profile-driven heartbeats (30–60 - seconds while active and 2–10 minutes while idle); -- no `app.getAppMetrics()` calls while diagnostics is closed; -- no telemetry database; -- no recurring shell probes; -- bounded PubSub queues and native ring history. - -The diagnostics page exposes the monitor's own process resource usage and -collection duration so the observer's cost is measurable. - -Failures are isolated: - -- native failure does not stop the server; -- Electron telemetry loss does not stop native telemetry; -- schema/version errors are visible in health; -- repeated native failures stop automatic restart churn until explicit retry; -- server and desktop shutdown close their respective streams and child process - scopes. - -## Future integration points - -High-value follow-up work can use the existing service boundaries: - -- opt-in file-path attribution through platform-specific tracing; -- process lifecycle events to reduce the chance of missing very short-lived - children; -- additional `ResourceAttribution` instrumentation for databases, checkpoints, - caches, and file synchronization; -- exported diagnostic bundles; -- adaptive sample intervals based on diagnostics visibility and active work. - -These additions should preserve the current rules: direct platform APIs, -schema-validated boundaries, explicit metric semantics, bounded retention, and -no mandatory telemetry persistence. +# Resource telemetry + +Process counters come from a [standalone Rust monitor](../../native/resource-monitor/src/main.rs) +using `sysinfo`. Electron main supplies host power and Electron process metrics. +Keeping native collection outside Node isolates collector crashes and avoids a +Node/Electron addon ABI matrix. Desktop and CLI servers use the same child-process +protocol. A missing or failed collector leaves the server running. + +## Collection cost + +The native child owns sampling and bounded in-memory history. The server requests +continuous snapshots only while diagnostics has live subscribers and fetches +history on demand. Consuming host power for background scheduling must not retain +live diagnostics. There is no telemetry database or recurring shell-probe fallback. + +History has independent bounds for age, snapshot count, process rows, and retained +bytes. A count limit alone cannot bound memory when command lines vary in size. +Large process trees therefore shorten the available history window. Linux task +enumeration is disabled because walking every `/proc//task/` directory +makes sampling itself expensive. + +Electron power updates travel over private inherited pipes, independent of the +renderer connection. Power events and slow heartbeats continue with diagnostics +closed; `app.getAppMetrics()` runs only on live demand. The receiver's stale deadline +must exceed the slowest configured heartbeat plus scheduling grace, or intentional +idle polling makes background policy oscillate between constrained and +unconstrained states. Headless servers leave unavailable power data unknown. + +## Measurement traps + +- Process identity includes start time because operating systems reuse PIDs. + Electron and native start times have different precision, so merging allows a + small tolerance. Process signaling rechecks the native identity with a fresh + sample. +- Snapshot sequence numbers belong to a monitor generation. Comparing them across + restarts would discard the new monitor's samples until its sequence caught up. +- Sampling can miss a process that starts and exits between samples. Cumulative + counters still yield deltas for processes observed across samples. +- Windows process I/O includes more than disk traffic. Unix counters report storage + I/O, which can differ from logical application reads and writes because of OS + caching. Keep instrumented logical I/O separate from these counters. +- Group totals accumulate observed deltas since telemetry started. Per-process + cumulative counters cover the operating system's lifetime for that process. +- Historical replay uses native samples without current Electron CPU or memory + metrics. Merging the latest Electron values would overwrite the past. + +A WSL backend needs a Linux monitor even though Electron runs on Windows. Windows +desktop packages currently supply only the Windows executable, so native process +telemetry for the WSL backend is unavailable. The inherited Electron power feed +still works. diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md deleted file mode 100644 index 3f8c58601e7f..000000000000 --- a/docs/internals/scripts.md +++ /dev/null @@ -1,215 +0,0 @@ -# Scripts - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -## First checkout - -T3 Code uses [Vite+](https://viteplus.dev/guide/). Install the global `vp` command, install -dependencies, then start the dev stack: - -```bash -curl -fsSL https://vite.plus | bash # Windows: irm https://vite.plus/ps1 | iex -vp i -vp run dev -``` - -Node 24 is required. Bun is not: the server picks Bun adapters when it detects Bun and falls back to -Node otherwise, and nothing in contributor setup needs it. - -`vp run dev` prints a one-time pairing URL. Open it so the first browser navigation is -authenticated. - -## Dev - -- `vp run dev`: Starts contracts, server, and web in watch mode. -- `vp run dev --share`: Also publishes the web port over HTTPS on this machine's tailnet. The - startup pairing URL is built against the shared origin, and the mapping is removed on exit. - Shared runs default to Vite's bundled dev mode (`T3CODE_BUNDLED_DEV=1`): a remote browser pays a - network round trip per import level in unbundled dev, which turns a cold module graph into - minutes of waterfall. Set `T3CODE_BUNDLED_DEV=0` to opt a shared run back out. - The web entry loads the app with a dynamic import so React refresh starts before shared UI - chunks run. Keep app imports out of that entry. A static import can work on the first load - and then fail on reload after Vite splits code for lazy routes. - Bundled dev uses Tailwind's watched files to rebuild CSS. Its Vite-only hot-update hook is - disabled in this mode because Rolldown does not supply the Vite server or module graph. -- `vp run dev --browser`: Auto-opens a browser. Off by default. The dev runner writes - `T3CODE_NO_BROWSER` itself from this flag, so setting `T3CODE_NO_BROWSER=0` in your environment has - no effect; use `--browser`. -- `vp run dev:server`: Starts just the server. It runs on Node (`node --watch src/bin.ts`), so - without Bun present it selects `NodePtyAdapter` and `NodeHttpServer`. -- `vp run dev:web`: Starts just the Vite dev server for the web app. -- `vp run dev:desktop`: Starts the Electron shell against the dev server. -- `vp run dev:marketing`: Starts the Astro marketing site. -- Pass dev-runner flags directly after the root task name, for example: - `vp run dev --home-dir /tmp/t3code-dev` - -### Dev state directories - -- Dev commands run from a linked **git worktree** default to that worktree's gitignored `.t3`, even - when `T3CODE_HOME` is set, storing state in `/.t3/userdata`. Pass `--home-dir ` to - choose another isolated directory explicitly. Submodules are not worktrees and keep the normal - precedence. -- From the **main checkout**, dev commands implicitly use `~/.t3/dev`, keeping development state - separate from `~/.t3/userdata`. An explicit `--home-dir ` stores state under - `/userdata`; the base directory remains available for caches, worktrees, and other shared - data. - -## Build, check, test - -- `vp run build`: Fans out over `apps/*`, `packages/*`, `oxlint-plugin-t3code`, and `scripts`. - Workspaces that define a build task run one: desktop, marketing, server (which depends on web), and - web. Shared packages are consumed and bundled transitively rather than built separately. -- `vp run build:desktop`: Builds the desktop pipeline (desktop plus server). -- `vp run start`: Runs the production server (serves the built web app as static files). -- `vp check`: Vite+ format, lint, and type checks. This repo sets `typeCheck: false` in its lint - options, so workspace type checking runs separately. -- `vp run typecheck`: Strict TypeScript checks for all packages. -- `vp run test`: Runs workspace tests. -- `vp run lint:mobile`: Mobile native static analysis (`scripts/mobile-native-static-check.ts`). -- `node apps/server/scripts/t3-sqlite-state.ts --base-dir ...`: Inspects or seeds - an isolated T3 SQLite database; writes create a private backup first. - -## Desktop artifacts - -- `vp run dist:desktop:artifact --platform --target --arch `: Builds a desktop artifact for a specific platform/target/arch. -- `vp run dist:desktop:dmg`: Builds a shareable macOS `.dmg` into `./release`. Architecture defaults - to the host, so this produces an arm64 DMG on Apple Silicon. Use `dist:desktop:dmg:arm64` or - `dist:desktop:dmg:x64`, or pass `--arch `, to force one. -- `vp run dist:desktop:linux`: Builds a Linux AppImage into `./release`. -- `vp run dist:desktop:win`: Builds a Windows NSIS installer into `./release`. `:arm64` and `:x64` - variants exist. - -### Linux AppImage prerequisites - -Linux AppImage packaging compiles the Rust resource monitor and the libsecret browser import -helper. Install a Rust toolchain, the standard C/C++ build tools, libsecret development headers, -pkg-config, and ImageMagick before running `vp run dist:desktop:linux`. - -Ubuntu and Debian: - -```bash -sudo apt-get update -sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick -``` - -Fedora: - -```bash -sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick -``` - -Arch Linux: - -```bash -sudo pacman -S rust base-devel libsecret pkgconf imagemagick -``` - -Linux desktop development also needs the C compiler, pkg-config, and libsecret headers. Its build -and launch commands compile `native/browser-secret/main.c` into the gitignored native build -directory. Releases place the executable in `resources/browser-secret`, outside `app.asar`. -The helper performs a read-only Chromium schema lookup through libsecret and writes the exact -secret bytes to its stdout pipe. Exit codes are 2 for a missing key, 3 for denied/locked access, -and 4 for other keyring failures; the desktop importer preserves these distinctions. - -The artifact script checks these capabilities before starting the web and desktop builds. If -anything is unavailable, it reports every failed check together and prints the Ubuntu/Debian -installation command. The check compiles and links tiny temporary programs, so it also catches -installed runtime X11 libraries that are missing their development headers or linker symlinks. - -### macOS DMG prerequisites - -Install the Xcode Command Line Tools and Rust before building a DMG: - -```bash -xcode-select --install -``` - -Install Rust from [rustup.rs](https://rustup.rs). The artifact script checks Cargo, Clang, Make, -`sips`, and `iconutil`. Universal builds additionally require `lipo`. It also verifies that Rust -has every requested target; add missing targets with: - -```bash -rustup target add aarch64-apple-darwin x86_64-apple-darwin -``` - -Unsigned local builds need no Apple credentials. Builds using `--signed` additionally require the -certificate, provisioning profile, team ID, and notarization configuration described below. - -### Windows installer prerequisites - -Install Rust from [rustup.rs](https://rustup.rs), Python 3, and Visual Studio Build Tools. In the -Visual Studio Installer, select **Desktop development with C++** and include: - -- MSVC x64/x86 build tools -- A Windows 10 or Windows 11 SDK -- MSVC Spectre-mitigated libraries - -ARM64 installers require the corresponding MSVC ARM64 build tools and Spectre-mitigated libraries -instead of the x64/x86 components. - -Add the Rust target matching the installer architecture: - -```powershell -rustup target add x86_64-pc-windows-msvc -# For an arm64 installer: -rustup target add aarch64-pc-windows-msvc -``` - -Windows supplies `tar.exe`; it is checked when `--wsl-prebuild` makes the artifact include the WSL -runtime. NSIS is downloaded by electron-builder and does not need a separate installation. -When `T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR=true` points the build at an existing resource monitor, -the artifact script skips the Rust and Visual Studio checks because it does not compile the monitor. -Unsigned local builds need no Azure credentials. Builds using `--signed` additionally require the -Azure Trusted Signing configuration described below. - -### Desktop `.dmg` packaging notes - -- Default build is unsigned/not notarized for local sharing. -- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source. -- The DMG chrome follows the release channel: neutral for Latest and the Nightly sky artwork for - Nightly. Blueprint artwork remains exclusive to Dev builds. Packaging rasterizes the selected - SVG into standard and Retina PNGs inside the disposable staging directory. -- The Finder window is 540×412 while its background is 540×380; the extra 32px accounts for the - title bar included in Finder's window bounds. -- Desktop production windows load the bundled UI from the `t3code://app/` root URL (not a - `127.0.0.1` document URL, and not an explicit `index.html` path). -- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an - auth token for WebSocket/API traffic. -- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first - launch. -- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg --keep-stage` -- To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. -- Signed macOS builds also require `T3CODE_APPLE_TEAM_ID` and - `T3CODE_MACOS_PROVISIONING_PROFILE`. The passkey RP domain is derived from - `T3CODE_CLERK_PUBLISHABLE_KEY` unless `T3CODE_CLERK_PASSKEY_RP_DOMAINS` overrides it. -- Windows `--signed` uses Azure Trusted Signing and expects: - `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`, - `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, and `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME`. -- Azure authentication env vars are also required (for example service principal with secret): - `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`. - -## Browser development - -`dev` and `dev:web` leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so the browser resolves the backend -from `window.location.origin`. Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the -server, allowing the same bundle to work from localhost or a tailnet hostname. - -## Running multiple dev instances - -Worktrees derive a preferred port offset from their path. - -- Default ports: server `13773`, web `5733` -- Shifted ports: `base + offset` -- Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop` - -Offset resolution, in order: - -1. `T3CODE_PORT_OFFSET`, which must be a non-negative integer. Negative values are rejected. -2. `T3CODE_DEV_INSTANCE`. An all-digit value is used directly as the offset; any other non-empty - value is hashed into one. -3. The worktree path hash. - -Collision scanning depends on the mode. `dev:web` scans only the web port and shifts only the web -offset. `dev:server` scans only the server port. `dev` and `dev:desktop` scan both and shift them -together as one shared offset. Explicit server or dev-URL overrides remove the corresponding port -from the availability check. Treat the `[dev-runner]` output as authoritative. diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 693a240bbc79..db24428ce54d 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -1,107 +1,57 @@ -# Server Update Architecture - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -Remote server updates use one stable launcher selected by the platform service manager (systemd on -Linux, launchd on macOS). Foreground CLI processes do not self-update, and a running server never -edits its service definition or durable service state. - -## Ownership - -The service files under `/runtime` are: - -- `service-launcher.mjs`, the stable process selected by the service manager; -- `service-state.json`, the launcher's durable selection state; -- `versions/`, immutable exact-version npm installs. - -The launcher is the only runtime writer of `service-state.json`. `t3 service install` and -`t3 service update` may replace the launcher and state while the unit is stopped. Server children -only communicate with the launcher over their inherited IPC channel. - -The state contains one active version and, at most, one update record: - -- `pending A → B` selects B as a retryable trial; -- `committed A → B` selects B for ordinary restarts; -- `rolled-back A → B` or `failed A → B` selects A; -- invalid state fails closed so the service manager cannot guess at a runtime. - -Every write uses same-directory replacement plus file and directory fsync. - -## Remote Update - -1. The active server installs `t3@` into a unique staging directory. -2. The target runs `__service-preflight` and verifies that the stable launcher supports its update - protocol. -3. The staging directory is renamed to its immutable version path only after preflight succeeds. -4. The active child sends `request-update`. The launcher validates the child and target, writes - pending state, generates the update ID, then replies `update-accepted`. -5. After a short response-flush grace period, the launcher stops the active child. -6. With SQLite quiescent, the launcher snapshots the database, WAL, and shared-memory files. -7. The launcher starts the target as a trial and gives it the pending update over IPC. -8. The trial runs migrations, acquires dependencies, binds HTTP, starts every long-running root - fiber, and verifies that each root is parked at the activation gate. -9. The trial sends `prepared`. The launcher durably commits B, deletes the snapshot, then replies - `committed`. -10. The child opens the existing activation gate, accepts commands, and publishes lifecycle ready - with the terminal update outcome. - -Post-commit startup does not call service `start`, `initialize`, `connect`, `load`, or `acquire` -operations. It only opens prepared gates and publishes prepared lifecycle state. - -The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within -120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the -snapshot, records rollback, and starts A. A durable restore marker makes an interrupted restore -resume before either version can boot. After commit, B is active and the service manager's normal -restart policy applies. - -## Database Rollback - -The launcher snapshots `state.sqlite`, `state.sqlite-wal`, and `state.sqlite-shm` after the old -server stops and before the trial starts. This makes trial migrations and writes reversible without -requiring down migrations. The snapshot is retained across launcher restarts and is removed only -after commit or after both restore and the terminal rollback state are durable. - -The protocol version is part of the safety boundary. A target that requires database snapshots is -blocked when the installed launcher is too old. Upgrade the launcher once with: - -```sh -npx t3@ service update -``` - -The local command stops the unit, selects the new launcher and exact runtime, then restarts the -service. Later releases, including releases with migrations, can use the remote trial path. - -Snapshots briefly require enough free disk for another copy of the SQLite files. Attachments and -other files under the state directory are outside this rollback boundary. - -## Client Correlation - -The update acknowledgement includes the launcher-generated update ID. After reconnecting, clients -wait for a lifecycle ready event carrying that same ID. `committed` completes the operation only -when the ready server is the target version. `rolled-back` and `failed` end it immediately with the -recorded reason. Older servers without an ID retain version-only reconnect behavior. - -## Capability and Compatibility - -The existing additive RPC and lifecycle schemas remain compatible with older clients. New servers -advertise remote self-update only when they have valid launcher context and a live IPC channel. -Desktop-managed servers advertise `desktopAppUpdate` when the desktop telemetry control fd is -attached. The progress RPC asks the desktop app to check and download, then returns a preparation -token while the backend is still connected. Only after the client receives that result does it send -`server.commitDesktopUpdate`. A successful commit closes the connection and must reconnect at the -prepared version. The desktop app and bundled server versions stay equal because -`scripts/update-release-package-versions.ts` bumps them together. If install fails, the desktop keeps its windows, restarts stopped backends, and -replays the failure for the same token. This two-phase handoff prevents backend shutdown from -dropping the only successful RPC result. Desktop servers without the capability direct the user to -update the desktop app locally. Other process shapes provide a manual command; the old detached -foreground respawn path no longer exists. - -## Source Map - -- Launcher and state machine: `apps/server/src/serviceLauncher.ts` -- IPC and durable state types: `apps/server/src/cloud/serviceProtocol.ts` -- Child IPC adapter: `apps/server/src/cloud/serviceLauncherClient.ts` -- Staging and preflight: `apps/server/src/cloud/pinnedRuntime.ts` and `servicePreflight.ts` -- Service installation: `apps/server/src/cloud/bootService.ts` -- Activation boundary: `apps/server/src/serverRuntimeStartup.ts` and `serverActivation.ts` -- Client outcome correlation: `packages/client-runtime/src/state/server.ts` +# Server updates + +A [stable launcher](../../apps/server/src/serviceLauncher.ts) owns the runtime +selected by systemd or launchd. It is the only runtime writer of durable service +state. Server children request updates over inherited IPC; they never rewrite +their service definition or select their own replacement. Local service commands +may replace the launcher and state while the service is stopped. Foreground CLI +processes do not self-update. + +Exact-version installs keep restarts independent of npm cache eviction or a moving +release tag. Installation and preflight happen in staging before publishing an +immutable runtime. Preflight checks the launcher protocol because a target that +needs new rollback guarantees cannot safely run under an older launcher. Upgrading +that launcher requires a local service update. + +## Commit boundary + +The launcher durably records the pending update before acknowledging it, then +stops the old child and starts the target as a trial. Service-state writes use +same-directory replacement with file and directory fsync. Invalid state stops +startup rather than guessing which runtime to boot. + +The trial must finish migrations, acquire dependencies, bind HTTP, and park every +long-running root at the activation gate before reporting `prepared`. The launcher +then commits the target version durably and replies `committed`. Only then may the +child release its gates, accept commands, and publish ready. Keep fallible startup +acquisitions before this boundary. A listener alone does not prove the runtime is +ready to commit. + +A failed or timed-out trial returns to the old version. After commit, the target +is authoritative and the service manager's ordinary restart policy applies. + +## Database rollback + +After the old child exits, the launcher snapshots SQLite's main file, WAL, and +shared-memory file. This makes trial migrations reversible without down +migrations. The snapshot is made once per update and survives launcher restarts; +replacing it during a retry could capture changes from the failed trial. + +Rollback stops the trial before restoring. A durable restore marker makes an +interrupted restore finish before either version boots. Keep the snapshot until +commit, or until both restoration and the terminal rollback state are durable. +Attachments and other files outside SQLite are outside this rollback boundary. + +## Client acknowledgement + +An accepted update is still pending. Clients correlate the launcher's update ID +with the ready event after reconnecting, then check the outcome and target version. +A reconnect alone cannot distinguish successful replacement from rollback. Older +servers without an update ID retain version-only correlation. + +Desktop updates have a separate two-phase handoff because installing the app stops +its bundled backend. Preparation returns a token while the connection is alive; +the client commits that token only after receiving it. Otherwise backend shutdown +could lose the only successful RPC result. The client must then observe the +prepared version after reconnecting. If installation fails, desktop restarts the +stopped backends and replays the failure for the same token. diff --git a/docs/internals/t3-code-connect-auth-flow.html b/docs/internals/t3-code-connect-auth-flow.html deleted file mode 100644 index 038132ee5cbf..000000000000 --- a/docs/internals/t3-code-connect-auth-flow.html +++ /dev/null @@ -1,1160 +0,0 @@ - - - - - - T3 Connect Architecture - - - -
-
-
Implemented architecture
-

T3 Connect Control Plane and Managed Endpoint Flow

-

- T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions - a managed HTTPS/WSS endpoint for that environment, and brokers proof-bound remote - connections. The local environment remains the authority that issues environment access - credentials. The hosted relay stores links, reconciles managed endpoint resources, - validates signed proofs, and delivers agent-activity notifications. -

-
- Client -> Relay: Clerk bearer or relay DPoP - Relay -> Environment: relay-signed health and mint proofs - Environment -> Relay: environment bearer plus signed activity proof - Client -> Environment: environment access token plus DPoP -
-
- -
-
-

Security Invariant

-

- A cloud identity is not an environment login. Remote access requires all of the - following: -

-
    -
  • a Clerk-authenticated T3 Connect user;
  • -
  • an active relay link for that user and environment;
  • -
  • a DPoP proof key held by the remote client;
  • -
  • a relay-signed mint request accepted by the linked local environment; and
  • -
  • an environment-issued credential bound to the remote client's DPoP thumbprint.
  • -
-
- The relay cannot mint an environment access token by itself. It can only ask the local - environment to mint a short-lived bootstrap credential for an authorized cloud user. -
-

During an honest connection flow, the relay cannot act as the remote client:

-
    -
  • - Normal environment API traffic goes directly from the remote client through the - managed endpoint. The relay does not receive the resulting environment access token. -
  • -
  • - The one-time bootstrap credential and resulting environment access token are bound to - the remote client's DPoP public-key thumbprint. Redeeming or using them requires - request proofs signed by the corresponding private key, which stays on the client. -
  • -
  • - Relay-signed health and mint proofs are short-lived, audience-bound, scoped, and - replay-guarded. They are accepted only by the narrow cloud endpoints, not as normal - environment API credentials. -
  • -
  • - Environment health and mint responses are signed by the linked environment and bound - to the request nonce, so a different process behind the tunnel cannot impersonate the - linked environment. -
  • -
-
- The hosted relay remains a trusted mint broker: it holds the cloud-mint signing key. - DPoP prevents credential reuse by the relay or tunnel transport during an honest flow, - but it does not make compromise of the relay signing authority harmless. -
-
- -
-

Product Boundaries

-

- The generic local connector is called the relay client. The current - implementation layer uses cloudflared, but that is an internal transport - detail rather than the product contract. -

-
    -
  • - The relay Worker owns cloud links, managed endpoint allocations, and notifications. -
  • -
  • - The environment server owns local sessions, environment keys, and access tokens. -
  • -
  • The relay client exposes only the environment's loopback HTTP server.
  • -
  • - Web and mobile clients connect directly to the managed environment endpoint after - bootstrap. -
  • -
-
-
- -
-

Current Topology

-
-flowchart LR
-  subgraph Clients["User-facing clients"]
-    Web["Desktop / hosted web UI"]
-    Mobile["Mobile app"]
-    CLI["Headless CLI"]
-  end
-
-  Clerk["Clerk
user identity and OAuth"] - - subgraph Relay["T3 Code Relay on Cloudflare"] - Worker["Relay Worker
HTTP API"] - Queue["APNs delivery queue
with dead-letter queue"] - Hyperdrive["Hyperdrive"] - CF["Cloudflare tunnel + DNS APIs"] - end - - DB["PlanetScale Postgres
prod database + stage branches"] - APNs["Apple Push Notification service"] - Traces["Axiom OTLP traces"] - - subgraph Local["Linked T3 environment"] - Env["Environment server
local auth authority"] - RelayClient["Relay client
cloudflared implementation"] - Secrets["Server secret store
keys + relay config"] - end - - Endpoint["Managed HTTPS/WSS endpoint"] - - Web -->|"Clerk bearer / relay DPoP"| Worker - Mobile -->|"Clerk bearer / relay DPoP"| Worker - CLI -->|"Clerk OAuth bearer"| Worker - Web --> Clerk - Mobile --> Clerk - CLI -->|"PKCE authorization code flow"| Clerk - Worker --> Hyperdrive --> DB - Worker --> Traces - Worker --> CF - Worker --> Queue --> APNs - Worker --> Endpoint - Endpoint --> RelayClient -->|"http://127.0.0.1:port"| Env - Env --> Secrets - Web -->|"environment token + DPoP
HTTPS / WSS"| Endpoint - Mobile -->|"environment token + DPoP
HTTPS / WSS"| Endpoint - Env -->|"environment bearer + signed activity proof"| Worker -
-
- -
-

Authentication and Transport Matrix

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
BoundaryTransportAuthenticationPurpose
Web or mobile -> relay link managementHTTPSClerk session-template bearer tokenCreate a link challenge, submit an environment proof, list links, or unlink.
CLI -> relay link managementHTTPSClerk OAuth access token obtained with PKCEReconcile the desired headless cloud link when the server starts.
Web or mobile -> relay protected client endpointsHTTPSRelay-issued DPoP token plus per-request DPoP proof - Check environment status, request a remote connection, and register mobile devices. -
Relay -> managed environment endpointHTTPS over the managed tunnelShort-lived relay-signed JWT request proof - Request a signed health response or ask the local environment to mint a credential. -
Environment -> relay activity publicationHTTPSRelay-issued environment bearer credential plus environment-signed JWT proof - Publish redacted agent-activity state for push notifications and Live Activities. -
Remote client -> environmentHTTPS and WSS over the managed tunnelEnvironment-issued access token bound to the client DPoP keyUse the normal environment HTTP APIs and request a one-time WebSocket ticket.
-
- -
-

Credential Ownership

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CredentialCreated byStored byUse
Clerk session-template JWTClerkWeb or mobile client - Authenticate cloud-user relay requests and bootstrap relay DPoP token exchange. -
CLI OAuth access and refresh tokenClerk OAuthLocal server secret storeAuthorize headless CLI link reconciliation.
Environment Ed25519 key pairLocal environment serverLocal server secret store - Sign link proofs, health responses, mint responses, and activity publication proofs. -
Relay cloud-mint key pairRelay deploymentPrivate key in relay Worker config; public key installed into the environmentSign relay-to-environment health and mint requests.
Relay environment bearer credentialRelayHashed in relay Postgres; plaintext in local server secret storeAuthenticate environment-to-relay agent-activity publication.
Relay DPoP access tokenRelayRemote client memory cache - Authorize status, connect, and mobile registration endpoints with proof of - possession. -
Environment bootstrap credentialLocal environment serverPassed relay -> remote clientOne-time exchange at the environment /oauth/token endpoint.
Environment access tokenLocal environment serverRemote clientAuthorize environment HTTP operations and WebSocket ticket issuance.
WebSocket ticketLocal environment serverRemote client until consumedShort-lived one-time credential for the WSS upgrade.
Relay client connector tokenCloudflare tunnel APILocal server secret store - Start the local relay client. It is passed through TUNNEL_TOKEN, not a - shell argument. -
-
- -
-
-

Flow 1: Link From the Desktop Web UI

-
-sequenceDiagram
-  participant U as Signed-in user
-  participant C as Desktop web UI
-  participant E as Local environment
-  participant R as Relay Worker
-  participant CF as Cloudflare APIs
-  participant RC as Local relay client
-
-  U->>C: Enable T3 Connect for a locally paired environment
-  C->>E: Check relay client availability
-  alt relay client is missing
-    C->>U: Prompt before download and install
-    U->>C: Confirm
-    C->>E: Stream relay-client install RPC
-    E-->>C: Progress stages
-  end
-  C->>R: POST /v1/client/environment-link-challenges (Clerk bearer)
-  R-->>C: Short-lived challenge
-  C->>E: POST /api/connect/link-proof (local relay:write bearer)
-  E->>E: Authenticate, reject forwarded authority, get or create key pair, validate loopback origin
-  E-->>C: Environment-signed link proof
-  C->>R: POST /v1/client/environment-links (Clerk bearer + proof)
-  R->>R: Verify challenge, proof, capabilities, nonce, and loopback origin
-  R->>CF: Reconcile tunnel, ingress, and CNAME
-  R->>R: Upsert user/environment link and issue environment bearer
-  R-->>C: Managed endpoint, relay config, and connector token
-  C->>E: POST /api/connect/relay-config (local relay:write bearer)
-  E->>RC: Start relay client with TUNNEL_TOKEN
-          
-
- The local link-proof endpoint requires relay:write, rejects forwarded - authority headers, and accepts only an exact loopback origin. Environment keypair - persistence is atomic across concurrent link attempts. -
-

- A locally paired mobile client uses the same relay challenge, local proof, relay link, - and local relay-config endpoints. The desktop web UI is the surface that checks relay - client availability and offers the managed install dialog. -

-
- -
-

Flow 2: Headless CLI Link

-
-sequenceDiagram
-  participant U as Operator
-  participant CLI as t3 connect CLI
-  participant Clerk as Clerk OAuth
-  participant S as Environment server on next start
-  participant R as Relay Worker
-  participant RC as Local relay client
-
-  U->>CLI: t3 connect link
-  CLI->>CLI: Resolve relay client
-  alt relay client is missing
-    CLI->>U: Confirm managed relay-client install
-    U->>CLI: Confirm
-    CLI->>CLI: Install with terminal progress updates
-  end
-  CLI->>Clerk: Browser PKCE authorization-code flow
-  Clerk-->>CLI: OAuth access and refresh tokens
-  CLI->>CLI: Persist desired cloud-link state
-  U->>S: Start T3 environment server
-  S->>R: Create challenge and submit local signed proof
-  R-->>S: Managed endpoint config and connector token
-  S->>RC: Start relay client
-          
-

- t3 connect login stores authorization without enabling exposure. - t3 connect unlink disables exposure while retaining authorization. - t3 connect logout also removes the stored CLI authorization. -

-
- -
-

Flow 3: Remote Connect Bootstrap

-
-sequenceDiagram
-  participant C as Web or mobile client
-  participant Clerk as Clerk
-  participant R as Relay Worker
-  participant E as Managed environment endpoint
-
-  C->>Clerk: Get Clerk session-template JWT
-  C->>R: POST /v1/client/dpop-token (Clerk JWT + DPoP proof)
-  R-->>C: Relay DPoP access token
-  C->>R: POST /v1/environments/:id/connect (relay DPoP)
-  R->>E: POST /api/t3-connect/mint-credential (relay-signed proof)
-  E->>E: Verify relay signer, linked user, scope, lifetime, cnf, and replay guards
-  E-->>R: Bootstrap credential + environment-signed proof
-  R->>R: Verify environment signature, nonce, endpoint, and DPoP binding
-  R-->>C: Managed endpoint + bootstrap credential
-  C->>E: POST /oauth/token (bootstrap credential + DPoP proof)
-  E-->>C: Environment DPoP-bound access token
-  C->>E: POST /api/auth/websocket-ticket
-  E-->>C: One-time WebSocket ticket
-  C->>E: WSS /ws with ticket
-          
-
- Relay-to-environment control requests disable redirects, use only a reconciled managed - endpoint allocation, and require a signed environment response before the relay returns - data to the client. -
-
- -
-

Flow 4: Agent Activity Notifications

-
-sequenceDiagram
-  participant E as Local environment
-  participant R as Relay Worker
-  participant DB as Relay Postgres
-  participant Q as APNs queue
-  participant APNs as Apple Push Notification service
-  participant M as Mobile app
-
-  E->>E: Project publishable thread state and redact failure detail
-  E->>R: POST /v1/environments/:environmentId/threads/:threadId/agent-activity
-  Note over E,R: Environment bearer credential + environment-signed per-state proof
-  R->>R: Verify credential, signature, scope, expiry, and nonce
-  R->>DB: Store current activity row
-  R->>Q: Enqueue push or Live Activity delivery jobs
-  Q->>APNs: Deliver signed APNs request
-  APNs-->>M: Push notification or Live Activity update
-          
-

- The local server publishes a deliberately narrow projection. Failed runs use a fixed - redacted summary, and detail strings are capped before publication. -

-
-
- -
-
-

Managed Endpoint Reconciliation

-

- Managed endpoint resources are keyed by (userId, environmentId). The relay - reserves a stable hostname and tunnel name before provisioning, then checkpoints the - tunnel ID, DNS record ID, and ready state in Postgres. -

-
-
- 1. Reserve deterministic allocation - Hostname and tunnel name derive from relay stage, cloud user, and environment ID. -
-
- 2. Reuse or create tunnel - Existing named tunnels are reused. Tunnel ingress is rewritten to the validated - loopback origin. -
-
- 3. Reconcile DNS - Existing CNAMEs are updated and duplicate records are removed. Create conflicts are - recovered by listing and reconciling the winning record. -
-
- 4. Mark ready - Connect and status requests use only ready allocations whose hostname still matches - the relay-managed namespace. -
-
-
- -
-

Unlink Cleanup

-

- Relay unlink removes the user's managed endpoint allocation before revoking the - user/environment link: -

-
    -
  • delete the managed DNS record if present;
  • -
  • delete the Cloudflare tunnel if present;
  • -
  • remove the allocation row;
  • -
  • revoke the user's environment link; and
  • -
  • - revoke the environment publication credential only after the final active link for - that environment key disappears. -
  • -
-

- Local unlink stops the relay client and removes the locally persisted relay URL, issuer, - linked user, environment publication credential, cloud-mint public key, connector - config, and agent-activity preference. -

-
-
- -
-

Cloudflare Tunnel Operational Profile

-

- Managed tunnels are private application backends, not general-purpose public hosting. The - relay provisions one named Cloudflare tunnel and one DNS record per active - (userId, environmentId) allocation. -

-
    -
  • - Lifecycle: the tunnel is created when an environment is linked, reused - across retry-safe reconciliation, kept for the lifetime of that link, and deleted on - unlink. If teardown fails, the allocation checkpoint remains so cleanup can be retried. -
  • -
  • - Origin: tunnel ingress is restricted to the linked environment server's - validated loopback HTTP origin. Arbitrary upstream hosts and raw TCP origins are - rejected. -
  • -
  • - Protocols: managed endpoints carry HTTPS request/response traffic and - WSS connections only. Remote clients use the normal environment HTTP APIs and - authenticated WebSocket RPC transport through the tunnel. -
  • -
  • - Relay-generated traffic: the hosted relay sends sparse HTTPS health - checks and credential-mint requests during status checks and remote connection - bootstrap. It does not proxy steady-state remote sessions. -
  • -
  • - Traffic shape: expected load is interactive and user-driven. Active - remote sessions may keep WebSockets open and exchange terminal, agent, and UI updates; - inactive linked environments should carry little or no tunnel traffic. -
  • -
  • - Capacity planning: the project is pre-launch, so there is not yet a - production bandwidth baseline. Tunnel count grows with active linked user/environment - allocations, while bandwidth and concurrent WebSockets grow with active remote use. -
  • -
-
- -
-

SSRF and Tunnel Hardening

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RiskImplemented control
A client asks the relay to tunnel an arbitrary host. - The environment and relay both accept managed tunnel origins only for loopback hosts - and valid ports. -
A forwarded request tricks local origin validation. - The local link-proof handler requires an exact loopback request URL and rejects - forwarded host or protocol headers. -
A stored endpoint is replaced with an arbitrary external URL. - Connect and status resolve a ready allocation from Postgres and require a hostname - under the configured managed endpoint zone. -
A managed endpoint redirects relay egress elsewhere. - Relay-to-environment status and mint requests set redirect handling to - manual. -
A process behind the tunnel returns attacker-controlled data. - The relay verifies environment-signed health and mint responses against the linked - environment public key, expected nonce, and request binding. -
A stolen remote access token is replayed. - Relay and environment access tokens are DPoP-bound; per-request proofs and replay - guards are required. -
The relay client token leaks through process listings or shell parsing. - The relay client is spawned without a shell and receives its connector token in - TUNNEL_TOKEN. -
-
- Managed tunnel hostnames live under the configured tunnel DNS zone. Serving them from a - dedicated registrable domain with a Public Suffix List entry remains an operational - isolation requirement before broad untrusted use. -
-
- -
-

Relay HTTP API

-

- The relay publishes an OpenAPI document at /openapi.json, interactive API - docs at /docs, and redirects / to the docs. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
EndpointAuthenticationPurpose
GET /healthNoneRelay health check.
GET /.well-known/oauth-authorization-serverNoneRelay OAuth token-exchange discovery metadata.
GET /.well-known/oauth-protected-resourceNoneRelay DPoP protected-resource discovery metadata.
GET /v1/environmentsClerk bearerList environments linked to the signed-in user.
POST /v1/client/environment-link-challengesClerk bearerCreate a short-lived user-bound environment link challenge.
POST /v1/client/environment-linksClerk bearer - Verify a local environment proof, reconcile a managed endpoint, and upsert the user - link. -
DELETE /v1/client/environment-links/:environmentIdClerk bearerRemove managed endpoint resources and revoke the user's link.
POST /v1/client/dpop-tokenClerk token in token-exchange payload plus DPoP proofIssue a relay DPoP access token for the requested supported scopes.
POST /v1/environments/:environmentId/statusRelay DPoPRequest and validate a signed environment health response.
POST /v1/environments/:environmentId/connectRelay DPoPRequest and validate an environment bootstrap credential.
POST /v1/mobile/devicesRelay DPoPRegister or update a mobile device for notifications.
POST /v1/mobile/live-activitiesRelay DPoPRegister a Live Activity push token.
DELETE /v1/mobile/devices/:deviceIdRelay DPoPUnregister a mobile device.
- POST /v1/environments/:environmentId/threads/:threadId/agent-activity - Environment bearer plus signed publish proofPublish redacted current agent activity for notification delivery.
-
- -
-

Environment Cloud HTTP API

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
EndpointAuthenticationPurpose
POST /api/connect/link-proofLocal environment token with relay:writeValidate local origin and sign an environment link proof.
POST /api/connect/relay-configLocal environment token with relay:writePersist linked relay configuration and start the relay client.
GET /api/connect/link-stateLocal environment token with relay:readRead the environment's current T3 Connect link state.
POST /api/connect/preferencesLocal environment token with relay:writeEnable or disable agent-activity publication.
POST /api/connect/unlinkLocal environment token with relay:writeStop the relay client and clear local T3 Connect configuration.
POST /api/t3-connect/healthRelay-signed proofReturn a signed environment health response.
POST /api/t3-connect/mint-credentialRelay-signed proofMint a DPoP-bound one-time bootstrap credential for a remote client.
POST /oauth/tokenBootstrap credential plus DPoP proofExchange a bootstrap credential for an environment access token.
POST /api/auth/websocket-ticketEnvironment access tokenIssue a one-time ticket for the WSS upgrade.
-
- -
-
-

Relay Client Installation

-

- Desktop web UI availability checks and installs are local WebSocket RPC methods: - cloud.getRelayClientStatus and streaming - cloud.installRelayClient. The install stream emits the current stage: - checking, waiting for lock, downloading, verifying, installing, validating, and - activating. -

-

- The web UI asks for confirmation before installation and displays a custom progress - dialog. The CLI invokes the same local relay-client service directly and reports the - same progress stages in the terminal. -

-
- -
-

Relay Infrastructure

-
    -
  • Cloudflare Worker for the hosted relay HTTP API.
  • -
  • PlanetScale Postgres database named t3coderelay.
  • -
  • - Production uses the shared database; non-production stages use database branches. -
  • -
  • - Cloudflare Hyperdrive with caching disabled and a constrained origin connection limit. -
  • -
  • Cloudflare tunnel and DNS bindings for managed endpoint reconciliation.
  • -
  • Cloudflare APNs delivery queue plus dead-letter queue.
  • -
  • A five-minute cron job that prunes expired DPoP replay rows.
  • -
  • Axiom OTLP trace dataset, scoped ingest token, and recent-spans view per stage.
  • -
-
-
- -
-

Deployment Configuration

- - - - - - - - - - - - - - - - - - - - - -
BoundaryConfiguration
Relay infrastructure - RELAY_API_ZONE_NAME, RELAY_TUNNEL_ZONE_NAME, optional - RELAY_DOMAIN, CLERK_PUBLISHABLE_KEY, - CLERK_SECRET_KEY, CLERK_JWT_AUDIENCE, and APNs - credentials. -
Source-built desktop/server cloud features - Optional T3CODE_RELAY_URL, T3CODE_CLERK_PUBLISHABLE_KEY, - and T3CODE_CLERK_CLI_OAUTH_CLIENT_ID. Release builds inject public - values at build time. -
Web and mobile cloud clients - Public Clerk publishable key, Clerk JWT template name, and relay URL. Relay URLs - must normalize to an absolute HTTPS origin without credentials, query, fragment, or - non-root path. -
-
- -
-

Implementation Checklist

-
    -
  • - Keep the relay cloud-mint private key hosted-only; install only its public key into - environments. -
  • -
  • - Keep environment signing private keys local and persist keypair creation atomically. -
  • -
  • - Require a local relay:write session before linking or changing relay - config. -
  • -
  • Validate relay URLs as secure absolute HTTPS origins before sending credentials.
  • -
  • Require loopback-only tunnel origins on both sides of the link-proof boundary.
  • -
  • - Resolve managed endpoint egress from ready allocation rows, not client-supplied URLs. -
  • -
  • Disable redirects on relay-to-environment requests.
  • -
  • Validate environment-signed response proofs before consuming tunneled responses.
  • -
  • Keep tunnel and DNS provisioning retry-safe and deprovision them on unlink.
  • -
  • - Revoke shared environment publication credentials only after the final active link - disappears. -
  • -
  • Redact and cap local agent failure details before publishing them externally.
  • -
-
- -
-

Standards References

-
    -
  • - RFC 8252: browser-based OAuth for - native apps, used by the CLI PKCE authorization flow. -
  • -
  • - RFC 8693: OAuth token exchange, - used for Clerk-token to relay-DPoP-token exchange. -
  • -
  • - RFC 9449: DPoP proof of possession, - used for relay and environment access tokens. -
  • -
-
-
- - - diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index 6f796123e98b..bf02eb3538be 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -1,273 +1,81 @@ # T3 Connect -> For maintainers. Using T3 Code? See [docs/user](../user/). - -T3 Connect uses one Clerk application for web, desktop, and mobile authentication. The relay verifies -two kinds of bearer credential: template JWTs generated from the `t3-relay` template with the shared -`t3-code-relay` audience, and Clerk OAuth tokens issued to the CLI. `verifyRelayClientBearerToken` in -`infra/relay/src/http/Api.ts` tries the template/session path first and falls back to OAuth -verification (`acceptsToken: "oauth_token"`), so the CLI's OAuth credential works without a JWT -template. - -For the wider system diagram, see -[t3-code-connect-auth-flow.html](./t3-code-connect-auth-flow.html). - -## Application Keys - -T3 Connect is disabled in a fresh clone. To enable it for source builds against the production -deployment, copy the repository-root example file: - -```sh -cp .env.example .env -``` - -`.env.example` carries the production public identifiers (the same values baked into official -release builds). To target a different Clerk application or relay, set the values yourself in a -repository-root `.env` or `.env.local` file: - -```dotenv -T3CODE_CLERK_PUBLISHABLE_KEY= -T3CODE_CLERK_JWT_TEMPLATE= -T3CODE_CLERK_CLI_OAUTH_CLIENT_ID= -T3CODE_RELAY_URL=https://relay.example.com -``` - -The shared client loader projects these canonical values into framework-specific `VITE_*` and -`EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new -client configuration should use the canonical names. - -Configuration precedence is: - -1. Process or CI environment variables. -2. Repository-root `.env.local`. -3. Repository-root `.env`. - -The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public -identifiers, not secrets. -Web, desktop, mobile, and bundled server builds statically inject the values they consume during -their build step. A built artifact does not need an environment file at runtime. CI release builds -should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, -`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and -production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS -environment. - -When any client-facing public value is absent, cloud UI is omitted. The `t3 connect` command group is -always registered: when the CLI public values are absent, `makeCli` in `apps/server/src/bin.ts` -registers a hidden fallback `connect` command that reports the missing configuration instead of -silently vanishing from help. The bundled server still accepts runtime overrides for self-hosted or -operator-managed deployments. - -For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay -deployment reads `RELAY_DOMAIN`, `RELAY_API_ZONE_NAME`, `RELAY_TUNNEL_ZONE_NAME`, -`CLERK_PUBLISHABLE_KEY`, and `CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in -deployment defaults. -`vp run --filter t3code-relay deploy` invokes Alchemy from the relay directory, so Alchemy loads -`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env` -with the deployed HTTPS relay URL. The relay still requires -`CLERK_SECRET_KEY` as an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application -environment or commit it to the repository. - -The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference -that database and provision isolated PlanetScale branches, so deploy `prod` before creating a -personal developer stage. - -## Headless CLI OAuth Application - -The `t3 connect` commands authorize a headless environment with a separate Clerk OAuth application. -This uses an OAuth public client with PKCE, so the CLI stores no client secret. - -In **Clerk Dashboard > OAuth applications**: - -1. Create an OAuth application for the T3 CLI. -2. Enable the **Public** option so authorization-code exchange uses PKCE. -3. Add **both** allowed redirect URIs: - - `http://127.0.0.1:34338/callback` for the loopback listener; - - `https://app.t3.codes/connect/callback` for the hosted out-of-band flow. This is - `connectCallbackUrl(DEFAULT_HOSTED_APP_URL)` from `packages/shared/src/connectAuth.ts`, so a - custom `T3CODE_HOSTED_APP_URL` means `$T3CODE_HOSTED_APP_URL/connect/callback` instead. - Omitting it breaks headless and SSH authorization. -4. Enable the `openid`, `profile`, and `email` scopes. -5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build - environment to the generated public client ID. - -Both CLI flows start at the hosted `/connect` page (`buildConnectAuthorizeRequestUrl` in -`packages/shared/src/connectAuth.ts`), which waits for a Clerk session and then forwards the request -to Clerk's `/oauth/authorize`. The CLI never opens `/oauth/authorize` directly: a signed-out browser -sent there goes through Clerk's sign-in redirect, which drops the authorize query parameters and -fails the flow with `unsupported_response_type` or an empty `state` (#5051). The loopback flow marks -the request with a `port` fragment parameter so the hosted page asks Clerk to redirect the -authorization code straight to `http://127.0.0.1:/callback`; the out-of-band flow omits it and -uses the hosted `/connect/callback` page instead. The CLI derives Clerk's frontend API URL from the -publishable key and calls only the `/oauth/token` endpoint directly. The relay is not involved in -the OAuth handshake; it only validates the issued Clerk bearer token when the CLI manages an -environment link. - -The connect command group is: - -```sh -t3 connect # default: onboarding -t3 connect login -t3 connect link # --publish-only -t3 connect status # --json -t3 connect publish # --disable -t3 connect unlink -t3 connect logout -``` - -`t3 serve` is a separate top-level command, not a connect subcommand. - -`t3 connect login` opens the Clerk authorization flow and stores the CLI credential without enabling -cloud exposure. `t3 connect link` installs the pinned managed `cloudflared` binary when needed, -authorizes when needed, and records durable intent to expose the environment. It works without a -running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the -managed tunnel. `t3 connect unlink` records disabled intent immediately, stops a reachable running -connector, and attempts to revoke the relay-side environment record. It retains the stored CLI -authorization so `t3 connect link` can re-enable exposure without another browser flow. `t3 connect -logout` performs the same cleanup and removes the stored CLI authorization. - -The background service has an independent lifecycle. Connect setup may offer to install it, but -logout leaves it running; manage it with `t3 service status`, `install`, `update`, and `uninstall`. - -### Headless and SSH authorization - -The loopback OAuth callback listener binds to port `34338`. That path only works when a browser on -the same machine can reach it, so `authorizeCli` in `apps/server/src/cli/connect.ts` automatically -selects the out-of-band flow when `--headless` is passed or when it detects SSH through -`SSH_CONNECTION` or `SSH_TTY`. The out-of-band flow prints the hosted `/connect` authorization URL -and accepts a pasted authorization code, so no port is involved. - -Port forwarding is therefore optional, not required. Forward the port only if you specifically want -the loopback flow over SSH: - -```sh -ssh -L 34338:127.0.0.1:34338 -``` - -## JWT Template - -In **Clerk Dashboard > JWT templates**, create a template with: - -| Setting | Value | -| ------- | ---------------------------- | -| Name | `t3-relay` | -| Claims | `{ "aud": "t3-code-relay" }` | - -Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set -`CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and -`CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value -is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still -selects the concrete relay deployment, but changing that URL does not require a JWT template change. - -## Desktop OAuth Redirect Allowlist - -The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme. -In **Clerk Dashboard > Native applications**, enable the Native API and add these entries under the -mobile SSO redirect allowlist: - -```text -t3code-dev://app/ -t3code://app/ -``` - -Local desktop development uses `t3code-dev://app`, while packaged builds use `t3code://app`. Add the -matching origin to each Clerk instance's Backend API `allowed_origins` array as well. The development -Clerk instance should only need `t3code-dev://app`; the production Clerk instance should only need -`t3code://app`. `@clerk/electron` owns the native request adapter, encrypted Clerk token persistence, -external-browser OAuth transport, and callback delivery for initial sign-in and linked-account flows. - -There is currently no Dashboard UI for `allowed_origins`. Preserve any existing entries and update -the instance through the Backend API: - -```sh -curl -X PATCH https://api.clerk.com/v1/instance \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $CLERK_SECRET_KEY" \ - -d '{"allowed_origins":["t3code://app"]}' -``` - -Never put `CLERK_SECRET_KEY` in the desktop app, a client-facing environment file, or a build -artifact. - -## Desktop Passkeys - -The production macOS bundle ID is `com.t3tools.t3code`. To enable native passkeys: - -1. Create an explicit macOS App ID for `com.t3tools.t3code` in the Apple Developer portal and enable - **Associated Domains**. -2. Create a compatible macOS provisioning profile for that App ID and the certificate used to sign - the distributed app. -3. In Clerk's Native API settings, add an iOS app with the same Apple Team ID and bundle ID. This is - also the configuration point for Electron/macOS passkeys. -4. Confirm Clerk serves `https:///.well-known/apple-app-site-association` and that - `webcredentials.apps` contains `.com.t3tools.t3code`. -5. Set the local or CI signing configuration described below. - -For a local signed build, add these values to `.env.local` or export them before invoking the -desktop artifact command: - -```dotenv -T3CODE_APPLE_TEAM_ID=ABC1234567 -T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile -# Optional: comma-separated override when Clerk's RP ID differs from the Frontend API hostname. -T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com -``` - -When `T3CODE_CLERK_PASSKEY_RP_DOMAINS` is absent, the build derives the RP domain from -`T3CODE_CLERK_PUBLISHABLE_KEY`. Signed macOS builds fail early if the Team ID, provisioning profile, -or RP-domain configuration is missing. The generated main-app entitlements include every configured -`webcredentials:` entry; helper apps keep Electron's minimal default entitlements. - -The normal `dev:desktop` launcher is unsigned and cannot complete macOS passkey ceremonies. For -renderer HMR, build and install a signed app first, run the renderer dev server, then launch the -installed app executable with `VITE_DEV_SERVER_URL` and `T3CODE_PORT` set. Rebuild the signed app -after native dependency, main-process, preload, entitlement, provisioning, or signing changes; -renderer-only changes can reuse the installed app. - -For the default development ports, run `pnpm dev:web` in one terminal and launch the installed -binary from another: - -```sh -VITE_DEV_SERVER_URL=http://127.0.0.1:5733 \ -T3CODE_PORT=13773 \ - "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)" -``` - -After changing Associated Domains, bump the build version before rebuilding; macOS may otherwise -reuse stale Shared Web Credentials metadata for the same app/version pair. - -Verify the installed bundle before testing: - -```sh -codesign --verify --deep --strict "/Applications/T3 Code (Alpha).app" -codesign -d --entitlements :- "/Applications/T3 Code (Alpha).app" -``` - -The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth -flow uses a custom redirect URI, add that exact URI to the same allowlist. - -## Sign-in Surfaces - -Signed-in users manage T3 Connect under **Connections**. The settings sidebar also has dedicated -controls, rendered by `SettingsSidebarNav.tsx`: `T3ConnectSidebarSignIn` in the footer shows a -**Sign in to T3 Connect** button while signed out, and `T3ConnectSidebarAvatar` shows a Clerk -`UserButton` account control while signed in. Both are gated on cloud public configuration. -Desktop renders the same web bundle, so it has them too. The waitlist enrollment flow from the -private beta was removed when Connect went GA; sign-up is open unless a Clerk restriction below is -enabled. - -## Restricting Sign-ups: Known-User Allowlist - -For a closed deployment where all permitted users are known in advance, restrict sign-up to -permitted email addresses or domains: - -1. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email - domain. -2. Enable the allowlist and save. -3. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or - manually created. - -Do not enable an empty allowlist: it blocks all new sign-ups. - -Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud -access. To remove an already-created user's access, ban that user in Clerk so their active -sessions are ended and future sign-ins are rejected. +T3 Connect uses Clerk for cloud identity. The relay manages environment links, +credentials for reaching environments, and managed tunnel allocations. After +bootstrap, clients send application traffic through the environment's tunnel +hostname; the relay Worker does not proxy their HTTP or WebSocket sessions. + +Clerk, deployment, and native authentication setup live in the +[Connect setup runbook](../operations/connect-setup.md). + +## The relay is a trusted broker + +An authenticated cloud user still needs an active environment link. The relay +asks that environment to mint a one-time bootstrap credential bound to the +client's DPoP key. The client exchanges it directly with the environment for an +[environment session](./environment-auth.md). The relay never receives that +session token, and possessing the bootstrap credential alone does not permit +redeeming it without the client's private key. + +Both sides authenticate this exchange. The environment accepts only bounded, +replay-guarded relay proofs for its own identity, linked user, and requested +operation. Signed environment responses bind the result to the request nonce; +mint responses also bind the credential to the client proof key. The relay +verifies those bindings before returning a credential. This prevents a different +process behind the tunnel from impersonating the linked environment. The checks +meet in the +[environment cloud handlers](../../apps/server/src/cloud/http.ts) and +[relay connector](../../infra/relay/src/environments/EnvironmentConnector.ts). + +The relay holds the signing authority for mint requests. DPoP protects an honest +exchange from credential reuse; it does not make a compromised relay signing +key harmless. Keep that trust assumption explicit when changing the protocol. + +Managed tunnels expose only a validated loopback HTTP origin. Link proof checks +reject forwarded authority headers, and the relay resolves endpoints from its +own managed allocations rather than a caller-supplied URL. Health and mint +requests must not follow redirects. These restrictions keep endpoint discovery +from turning into arbitrary relay egress or exposing another service on the +environment host. + +## A link outlives a connector process + +CLI authorization, desired exposure, and a running connector have different +lifetimes. Linking can record intent while the server is stopped. Startup +reconciles that intent. CLI logout removes the stored cloud credential and +disables exposure without uninstalling the environment's background service. + +Managed allocations belong to a user/environment pair. Provisioning checkpoints +external tunnel and DNS resources so retries can reconcile partial work. A +normal shutdown of a CLI-managed link releases its tunnel to avoid paying for +an idle resource, retaining the hostname reservation for the next startup. +It also retains the allocation record so the environment remains "offline" +rather than becoming "not authorized". + +Two cases must retain the tunnel across shutdown. A link installed through a +client has no startup provisioning path and depends on its stored connector +token. An update handoff immediately starts a replacement server, and replacing +the tunnel would add routing propagation delay to every update. These exceptions +belong to [shutdown handling](../../apps/server/src/cloud/http.ts). + +Release and unlink claim the allocation generation before deleting external +resources. A delayed cleanup must not delete a tunnel reused by a concurrent +restart or relink. Unlink commits authorization revocation before external +teardown, because a database failure must leave the active link usable. Failed +teardown retains enough state to retry. See the +[managed endpoint lifecycle](../../infra/relay/src/environments/ManagedEndpointProvider.ts). + +## OAuth traps + +Interactive clients and the headless CLI use the same Clerk application but +different credentials. The relay accepts both session-template JWTs and CLI +OAuth tokens; requiring a JWT template for the CLI would reject valid logins. +The CLI is a public OAuth client using PKCE and stores no client secret. + +CLI authorization starts on the hosted `/connect` page so sign-in completes +before entering Clerk's authorize endpoint. Sending a signed-out browser +straight to that endpoint loses the authorize parameters during the sign-in +redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) preserves +PKCE and state for both loopback and pasted-code callbacks. SSH and headless +sessions use the pasted-code flow because the browser cannot ordinarily reach a +listener on the remote machine. diff --git a/docs/internals/terminal-runtime.md b/docs/internals/terminal-runtime.md index addfa1ce093a..ca5f0d46ead6 100644 --- a/docs/internals/terminal-runtime.md +++ b/docs/internals/terminal-runtime.md @@ -1,52 +1,44 @@ # Terminal runtime -The environment server owns terminal processes, retained output history, and -session lifecycle. Web, desktop, and mobile clients attach to the same -server-owned session over the environment RPC connection. The desktop renderer -does not own a separate PTY. Clients can reconnect to a running PTY or share -it with another client. - -## Output path - -PTY output follows this path: - -```text -PTY callback - -> ordered process-event drain - -> bounded retained-history append - -> live terminal output event - -> coalesced history persistence -``` - -Live output events contain only the new PTY data. Full retained history is -materialized only when the server returns a snapshot or when the coalescing -persistence worker writes the latest state. - -Retained history is limited to 5,000 lines and 8 MiB of UTF-8 text per terminal. -The server discards the oldest output when either limit is reached. A byte -cutoff can shorten the oldest retained line, but does not split a Unicode code -point. Live output is not truncated. - -History uses small chunks with byte and newline counts. Appending output scans -the new text and any removed chunk prefix, not the full retained history. -Empty lines, incomplete final lines, and trailing newlines remain unchanged -within the limits. Split surrogate pairs are joined before byte eviction. - -Discard each chunk's string reference as soon as it leaves retained history. -Array compaction can run later. Shared web and mobile client state retains at -most 512 KiB, so each client can display less scrollback than the server keeps. - -Measure sustained-output changes against a full retained history so terminal -throughput does not regress unnoticed. - -## Persistence - -History persistence is keyed by terminal session and coalesces pending writes. -The worker reads the newest bounded-history state after its debounce instead -of receiving a newly materialized full string for every PTY callback. Clear, -restart, close, and final flush operations still force the latest state to -disk before their lifecycle boundary completes. - -Restoration reads at most the last 8 MiB from current and legacy history files. -It skips an incomplete UTF-8 code point at the start and applies the line limit -before rewriting oversized files. File handles close before that rewrite. +The environment server owns PTYs, session lifetime, and retained output. Every +client, including the desktop renderer, attaches through the environment connection. +This lets clients reconnect or share a running session. Renderer choices stay local +to each client and do not change terminal contracts. + +## Output and retention + +[Terminal history](../../apps/server/src/terminal/Manager.ts) is incremental. +PTY callbacks append new chunks; live events carry only those chunks. Materializing +or copying full scrollback on every callback makes output cost grow with retained +history, so snapshots and coalesced persistence are the materialization boundaries. +Persistence queues the mutable history buffer and reads its latest value when the +write runs. Clear, restart, and close must drain writes before completing their +lifecycle boundary. + +Server history is capped at 5,000 lines and 8 MiB of UTF-8 text per terminal, so a +long unterminated line cannot bypass retention. Eviction removes the oldest output +without splitting Unicode code points; live output is not truncated. Release +discarded chunk references immediately, even if array compaction happens later. +Client buffers have a separate 512 KiB cap. Measure throughput with full scrollback +when changing this path. + +Restoration must read only the bounded tail of current or legacy history files, +skip any incomplete UTF-8 prefix, and apply the line limit. Close the read handle +before rewriting the capped file. Reading whole old logs would defeat the memory +bound during startup. + +## Renderer ownership + +Android and web use the same `libghostty-vt` C ABI for terminal behavior. Platform +adapters own drawing and input integration, and React stays out of terminal frames. +The web adapter shares one WebAssembly instance per browser tab while each terminal +owns and frees its own handles. The canonical upstream pin is +[`native/libghostty-vt/VERSION`](../../native/libghostty-vt/VERSION); both native and +web artifacts must be rebuilt when it changes. Web embeds the revision in its build +info so the ABI check can detect drift without a second pin. + +Restoring scrollback must not send terminal replies to the current shell. Historical +device queries can otherwise provoke fresh replies that appear as junk at the +prompt. The server strips query/response traffic from retained history, and the +[web renderer](../../apps/web/src/terminal/ghostty/core.ts) detaches its PTY writer +during replay. Preserve both protections when changing retention or renderer code. diff --git a/docs/internals/voice-input.md b/docs/internals/voice-input.md index dd37f2a86957..55e3007a4872 100644 --- a/docs/internals/voice-input.md +++ b/docs/internals/voice-input.md @@ -1,102 +1,20 @@ # Voice input -> For maintainers. Using T3 Code? See [voice input on iPhone](../user/composer.md#voice-input-on-iphone). - -Voice input produces editable composer text. The current implementation records on the client and -transcribes locally with Apple's `SpeechAnalyzer` and `SpeechTranscriber` on supported iOS 26+ -devices. Environment-provided transcription and transcription on web and desktop are not implemented. - -## Current boundaries - -The shared [`VoiceInputController`][controller] in `packages/client-runtime` owns preparation, -recording, transcription, cancellation, temporary-file cleanup, and insertion into the captured -draft selection. Applications import it through the [voice-input entry point][voice-input] as -`@t3tools/client-runtime/voice-input`. Its dependencies separate capture from transcription; the -controller imports neither React Native nor an Apple transcription API. - -The shared [transcription contract][transcription] defines `VoiceTranscriber`, -`PreparedVoiceTranscription`, and transcription errors. The controller calls `getTranscriber()` once -at the start of an operation, before asking for microphone permission. Preparation returns a resolved -locale and a bound `transcribe` function. The controller retains that result for the recording, so a -selection change cannot prepare with one implementation and transcribe with another. - -[`useVoiceInputController`][hook] supplies Expo audio capture, microphone permissions, audio-session -management, waveform samples, and app and navigation lifecycle handling. It normalizes Expo's -`mediaServicesDidReset` into a generic recorder error. [`voiceTranscription.ios.ts`][ios] adapts -`@react-native-ai/apple` through `getLocalVoiceTranscriber()`, capturing the requested device locale -and binding the prepared transcriber to Apple's resolved locale. The other-platform binding returns -no local transcriber. That result describes the local implementation, not whether a client could use -an environment's transcription service. - -Mobile's [`voiceInputPresentation.ts`][presentation] maps shared state to toolbar labels and actions. -Waveform and toolbar rendering stay in mobile. The composer edits draft text without selecting a -speech vendor. -Recording captures the draft owner, revision, text, and selection. A late transcript cannot overwrite -a different or edited draft. Only normal message submission sends the resulting text to an agent. - -Each operation passes one `AbortSignal` through preparation and transcription. Cancellation -invalidates the operation and aborts that signal immediately. Implementations settle their promises -only after their underlying work stops. The Apple binding checks cancellation between asynchronous -steps but cannot interrupt an in-flight native request. The controller retains its session until -that work settles, ignores its result, and cleans up the recording. - -## Ownership decisions - -The extension boundary distinguishes transcription on the client device from transcription through -the composer's environment. These constraints apply when adding selectable transcription services: - -- Local means the client device, regardless of which machine hosts the environment. A device's lack - of local recognition does not prevent it from recording audio for an environment service. -- Remote service configuration and API keys belong to the environment. The environment calls the - external service. Clients receive service identifiers, labels, and availability information, never - credential values. Transcription services are independent of coding-agent `providerInstances`; - selecting OpenAI for transcription does not select Codex for the thread. -- The client owns its transcription preference, scoped by stable `environmentId`. Its choices are - supported local recognition and the services exposed by the composer's environment. A service ID - is meaningful only within that environment. Different clients can make different choices. -- Resolve and capture the environment, selected service, and locale when an operation starts. - Preparation and transcription use the same selection; preference changes affect the next - recording. Capture environment identity explicitly rather than recovering it from a draft key. - Keep the existing draft-owner and revision checks before inserting text. -- If the selected option is unavailable, report that state and let the user choose another option. - A local failure must not silently upload audio, and a disconnected environment must not redirect - a recording to another environment or service. -- Transcription audio is temporary input, separate from durable chat attachments and messages. - Remote adapters need cancellation of upload and transcription where supported, cleanup after - success, failure, or cancellation, and the same protection against late results as local transcription. - -## Existing integration points - -[`ServerSettingsService`][settings] and [`ServerSecretStore`][secrets] provide environment-owned -configuration and secret persistence. Existing settings redaction handles coding-provider environment -variables only. Any transcription credential fields need their own explicit separation and redaction -before settings responses or subscriptions reach client caches. - -[`ExecutionEnvironmentCapabilities`][capabilities] handles version skew. Remote transcription must be -opt-in: a missing transcription capability means unsupported. The authenticated server-config -subscription and [shared environment state][server-state] already distribute configuration per -environment. A transcription service catalog belongs behind that capability and authenticated -boundary. Older servers expose no remote transcription choices. - -The [attachment upload contracts][uploads] and [shared upload operations][attachment-state] provide a -pattern for authorized binary uploads through an environment, including remote connections. Their -existing chat-attachment retention is not a transcription cleanup policy. - -Future service selection and environment requests belong alongside the controller in -`packages/client-runtime`, with wire contracts in `packages/contracts`. Capture and native local -recognition remain client-specific. An environment-backed transcriber implements the same shared -contract, with its environment and service bound when selected. The controller does not own service -credentials, provider SDKs, or transport selection. - -[controller]: ../../packages/client-runtime/src/voice-input/controller.ts -[voice-input]: ../../packages/client-runtime/src/voice-input/index.ts -[transcription]: ../../packages/client-runtime/src/voice-input/transcription.ts -[hook]: ../../apps/mobile/src/features/voice-input/useVoiceInputController.ts -[presentation]: ../../apps/mobile/src/features/voice-input/voiceInputPresentation.ts -[ios]: ../../apps/mobile/src/native/voiceTranscription.ios.ts -[settings]: ../../apps/server/src/serverSettings.ts -[secrets]: ../../apps/server/src/auth/ServerSecretStore.ts -[capabilities]: ../../packages/contracts/src/environment.ts -[server-state]: ../../packages/client-runtime/src/state/server.ts -[uploads]: ../../packages/contracts/src/assets.ts -[attachment-state]: ../../packages/client-runtime/src/state/attachments.ts +Transcription edits a composer draft. It does not submit an agent turn. Audio is +temporary client input, and only normal message submission sends the resulting +text. The current implementation transcribes locally on supported iOS devices; +environment-backed transcription is not implemented. + +The [shared controller](../../packages/client-runtime/src/voice-input/controller.ts) +owns the operation while the client supplies capture and transcription. Preparation +binds the transcriber and resolved locale for the whole recording. Draft ownership, +text, and revision are captured before recording and checked before insertion, so +a late transcript cannot overwrite a draft that was edited or replaced. + +Cancellation invalidates a result immediately, but resources stay owned until the +underlying work settles. Apple's native transcription call cannot be interrupted +once started. Releasing the session or deleting its recording when the abort signal +fires would race that work. The [transcription contract](../../packages/client-runtime/src/voice-input/transcription.ts) +therefore requires implementations to settle only after their work has stopped; +the [Apple binding](../../apps/mobile/src/native/voiceTranscription.ios.ts) checks +cancellation between native calls and discards late results. diff --git a/docs/internals/work-artifacts.md b/docs/internals/work-artifacts.md deleted file mode 100644 index 157a6174507d..000000000000 --- a/docs/internals/work-artifacts.md +++ /dev/null @@ -1,25 +0,0 @@ -# Engineering work artifacts - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -Keep planned work out of the source tree. Code search should return the product as it exists, not a mix of current behavior and abandoned intentions. - -## Current facts belong in the docs - -Put durable architecture, constraints, and operational knowledge in `docs/internals/` or `docs/operations/`. Write these documents in the present tense and update them with the code they describe. - -When the reason for a decision will matter after implementation, record it in the relevant internal document. Use a separate decision record under `docs/internals/` only when the rationale does not fit cleanly beside the current architecture. - -## Planned work belongs in GitHub - -Track active maintainer work in its GitHub issue or project item. The tracking item should state the outcome, constraints, and acceptance criteria, then link the pull requests that implement it. Split large efforts into one durable specification and small work items that can each close independently. - -Close completed items. Update or delete invalidated work before starting the next implementation session. External proposals follow [CONTRIBUTING.md](../../CONTRIBUTING.md) and belong in Ideas discussions rather than issues. - -## Temporary work stays temporary - -Keep agent scratch files, exploratory research, transcripts, and session handoff notes outside the worktree. They are inputs to the work, not project documentation. - -`.plans/` is gitignored as a safety net for legacy tools. Its presence does not make it an accepted project artifact. Pull requests must not add implementation plans or temporary research under another name. - -A pull request records what changed and why. If a fact must survive after the pull request merges, update the relevant documentation. Otherwise, the tracking item and pull request are the record. diff --git a/docs/internals/workspace-layout.md b/docs/internals/workspace-layout.md deleted file mode 100644 index e933e4528915..000000000000 --- a/docs/internals/workspace-layout.md +++ /dev/null @@ -1,63 +0,0 @@ -# Workspace layout - -> For maintainers. Using T3 Code? See [docs/user](../user/). - -A pnpm workspace driven by [vite-plus](https://vite.plus) (`vp`). See [scripts.md](./scripts.md) for -the task commands. - -## apps - -- `apps/server` (`t3`): the execution runtime and the published CLI. Owns orchestration, provider - drivers, checkpointing, VCS, terminals, filesystem access, auth, and the HTTP + WebSocket surface. - Also serves the built web app. -- `apps/web` (`@t3tools/web`): React + Vite UI. Consumes the shared client runtime and adds routing, - components, and web-specific platform layers. -- `apps/desktop` (`@t3tools/desktop`): Electron shell. Supervises a desktop-scoped `t3` backend, - loads the web bundle over the `t3code://` protocol, and owns SSH-managed remote environments. -- `apps/mobile` (`@t3tools/mobile`): Expo/React Native client. Same client runtime composition as - web, different platform layer and UI. -- `apps/marketing` (`@t3tools/marketing`): Astro marketing site. - -## packages - -- `packages/contracts` (`@t3tools/contracts`): shared Effect Schema definitions. RPC group, - orchestration commands/events/read model, auth scopes, environment descriptors, settings. -- `packages/shared` (`@t3tools/shared`): framework-agnostic utilities used by server and clients - (`DrainableWorker`, git and source-control helpers, relay auth and signing, DPoP, semver, logging, - observability, and more). -- `packages/client-runtime` (`@t3tools/client-runtime`): connection lifecycle, authorization, RPC - session, environment registry, and Atom-based domain state shared by web and mobile. See its - [README](../../packages/client-runtime/README.md). -- `packages/ssh` (`@t3tools/ssh`): SSH config parsing, auth prompts, command execution, and the - tunnel/environment manager behind desktop-managed SSH environments. -- `packages/tailscale` (`@t3tools/tailscale`): Tailscale CLI wrapper, including the - `ensureTailscaleServe` / `disableTailscaleServe` serve lifecycle the server drives. -- `packages/effect-acp` (`effect-acp`): Effect client and agent implementation of the Agent Client - Protocol, used by ACP-speaking provider drivers. -- `packages/effect-codex-app-server` (`effect-codex-app-server`): Effect client for the - `codex app-server` JSON-RPC protocol. - -## infra - -- `infra/relay` (`t3code-relay`): the hosted T3 Connect relay, deployed with Alchemy. Handles - environment discovery, cloud-side records, and mobile notifications. It is not in the hot path; - after connect, client traffic goes directly to the environment. See - [t3-connect.md](./t3-connect.md). - -## Other top-level directories - -- `scripts/`: workspace tooling run through `vp run`. Dev runner, desktop artifact builds, release - helpers, mobile static checks and showcase capture, update-manifest merging. -- `assets/`: brand and app icon sources per channel (`dev`, `nightly`, `prod`). -- `patches/`: pnpm patches for pinned upstream dependencies. -- `oxlint-plugin-t3code/`: repo-specific lint rules. -- `experiments/`: throwaway prototypes. Not part of the shipped build. -- `docs/`: this documentation tree. - -## Import conventions - -`@t3tools/shared` and `@t3tools/client-runtime` use explicit subpath exports with no barrel index and -no root export. Import the narrow path (`@t3tools/shared/DrainableWorker`, -`@t3tools/client-runtime/state/threads`) rather than the package root. Files that are not exported -are implementation details. `@t3tools/contracts` does export a root alongside `./settings` and -`./relay`. diff --git a/docs/operations/connect-setup.md b/docs/operations/connect-setup.md new file mode 100644 index 000000000000..49b93e49c9c4 --- /dev/null +++ b/docs/operations/connect-setup.md @@ -0,0 +1,126 @@ +# T3 Connect setup + +Deployment and client configuration for T3 Connect. The [architecture note](../internals/t3-connect.md) +explains the trust boundaries; the [relay README](../../infra/relay/README.md#deployment) owns relay +provisioning instructions. + +## Public application configuration + +T3 Connect is disabled in a fresh clone. To build against the production deployment, copy the +repository-root example: + +```sh +cp .env.example .env +``` + +For another deployment, set these values in the repository-root `.env` or `.env.local`: + +```dotenv +T3CODE_CLERK_PUBLISHABLE_KEY= +T3CODE_CLERK_JWT_TEMPLATE= +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID= +T3CODE_RELAY_URL=https://relay.example.com +``` + +Process variables take precedence over `.env.local`, then `.env`. Use these canonical names; +the build loader supplies framework-specific aliases. These values are public identifiers. +`CLERK_SECRET_KEY` belongs only in the relay's secrets, never in client configuration. + +Client and bundled-server builds embed the public values, so set them before building. +EAS preview and production environments need the publishable key, JWT template name, and relay URL. +Bundled servers also accept runtime overrides for operator-managed deployments. + +Copy `infra/relay/.env.example` to `infra/relay/.env` for relay deployment settings. +Deploy `prod` before personal stages because it owns the retained database that their branches +depend on. The deploy wrapper writes the resulting relay URL back to the root `.env`. + +## CLI OAuth application + +In Clerk's OAuth applications settings: + +1. Create a public OAuth application for the T3 CLI, using authorization-code exchange with PKCE. +2. Allow both redirect URIs: `http://127.0.0.1:34338/callback` and + `https://app.t3.codes/connect/callback`. A custom `T3CODE_HOSTED_APP_URL` needs its own + `/connect/callback` URL. Headless and SSH authorization depend on the hosted redirect. +3. Enable the `openid`, `profile`, and `email` scopes. +4. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release + build environments. + +## JWT template + +Create a Clerk JWT template named `t3-relay` with claims: + +```json +{ "aud": "t3-code-relay" } +``` + +Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` for clients and +`CLERK_JWT_AUDIENCE=t3-code-relay` for the relay. The production relay deployment environment +also defines `CLERK_JWT_TEMPLATE`. The audience stays the same across relay stages; the relay +URL selects the deployment. + +## Desktop OAuth redirects + +Enable Clerk's Native API and add the desktop redirects to its SSO redirect allowlist: + +```text +t3code-dev://app/ +t3code://app/ +``` + +Add the corresponding origin to the Clerk instance's Backend API `allowed_origins` array. +Development uses `t3code-dev://app`; production uses `t3code://app`. Update the array with +`PATCH https://api.clerk.com/v1/instance` using the Clerk secret key, preserving existing entries. +The Clerk Electron integration handles token +persistence and system-browser callback delivery. + +## Desktop passkeys + +For a production macOS app with bundle ID `com.t3tools.t3code`: + +1. Create an explicit macOS App ID in the Apple Developer portal with **Associated Domains**. +2. Create a provisioning profile for that App ID and the distribution signing certificate. +3. In Clerk's Native API settings, add an iOS app with the same Apple Team ID and bundle ID. + This setting also configures Electron/macOS passkeys. +4. Check `https:///.well-known/apple-app-site-association`. Its + `webcredentials.apps` must include `.com.t3tools.t3code`. +5. Configure signing as described in the [release runbook](./release.md#2-apple-signing--notarization-setup-macos). + +Local signed builds additionally use: + +```dotenv +T3CODE_APPLE_TEAM_ID=ABC1234567 +T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile +# Override only when the RP domain differs from the Clerk Frontend API hostname. +T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com +``` + +Without the override, the build derives the RP domain from the Clerk publishable key. +After changing Associated Domains, bump the build version before rebuilding. macOS can otherwise +reuse stale Shared Web Credentials metadata for the same app/version pair. + +The ordinary `dev:desktop` launcher is unsigned and cannot exercise macOS passkeys. For renderer +HMR, install a signed build, start `vp run dev:web`, and launch the installed executable with the +actual web and server ports. For example, with the default ports: + +```sh +VITE_DEV_SERVER_URL=http://127.0.0.1:5733 \ +T3CODE_PORT=13773 \ + "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)" +``` + +Rebuild the signed app after native dependency, main-process, preload, entitlement, provisioning, +or signing changes. Renderer edits can reuse it. Verify the installed bundle before testing: + +```sh +codesign --verify --deep --strict "/Applications/T3 Code (Alpha).app" +codesign -d --entitlements :- "/Applications/T3 Code (Alpha).app" +``` + +## Restricting sign-ups + +Use Clerk's allowlist for permitted email addresses or domains, or Restricted mode for invitation-only +sign-up. An enabled empty allowlist blocks all new sign-ups. + +Sign-up restrictions do not revoke an existing account's access. Ban the account in Clerk when +its active sessions and future sign-ins must be disabled. diff --git a/docs/operations/development.md b/docs/operations/development.md new file mode 100644 index 000000000000..dec3e8681fd3 --- /dev/null +++ b/docs/operations/development.md @@ -0,0 +1,140 @@ +# Development + +## First checkout + +Install `vp` using the [root README](../../README.md#install-vp). The checkout requires Node 24; +Bun is optional. From the repository root: + +```sh +vp i +vp run dev +``` + +Open the one-time pairing URL printed by the dev runner. The bare origin does not authenticate +a new browser. + +## Choosing a dev process + +Use `vp run dev` for server and web, or `vp run dev:desktop` for the Electron client. +`dev:server` and `dev:web` start those processes separately. +See the [mobile README](../../apps/mobile/README.md) for native builds and Metro. + +Flags go directly after the task name, for example `vp run dev --home-dir /tmp/t3code-dev`. +Add `--browser` to open a browser automatically. + +### State and ports + +Linked worktrees default to their own `.t3/userdata`, even when `T3CODE_HOME` is set. +The main checkout defaults to `~/.t3/dev/userdata`. An explicit `--home-dir` wins in both cases. +Never run a development server against the live `~/.t3/userdata`. +See [test data](../../AGENTS.md#test-data) for copying a consistent database snapshot. + +Read ports from the `[dev-runner]` output. Worktrees derive stable preferences from their paths, +but occupied ports can shift them. `T3CODE_PORT_OFFSET` or `T3CODE_DEV_INSTANCE` can select a +different preference when needed. + +### Sharing and remote debugging + +`vp run dev --share` publishes the web port over the machine's tailnet and prints a pairing URL +for that origin. Give the tester the complete URL, including its token. The dev runner removes +its mapping on exit. + +Leave `VITE_HTTP_URL` and `VITE_WS_URL` unset. Vite proxies the backend through the browser's +origin so the same build works over localhost and remote connections. + +Shared runs enable bundled dev to avoid a network round trip for each import level. +`T3CODE_BUNDLED_DEV=0` opts out when debugging bundler differences. Two reload traps matter +when changing this setup: + +- The web entry must dynamically import the app so React refresh initializes before application + chunks. Static imports can work on first load and fail after a route split. +- Bundled dev rebuilds Tailwind through watched files. Its ordinary Vite hot-update hook expects + a server/module graph that Rolldown does not provide. + +The workarounds live in the [web entry](../../apps/web/src/bootstrap.ts) and +[Tailwind plugin](../../apps/web/vite/tailwind.ts). + +## Checks + +Run checks for the files and packages you changed: + +```sh +vp test run +vp lint +vp run --filter typecheck +``` + +Use `vp run lint:mobile` for native mobile changes. CI owns the full suite; see +[ci.yml](../../.github/workflows/ci.yml) for its current jobs. +The [manual Windows lane](../../.github/workflows/windows-tests.yml) is available for focused +Windows investigation while that suite is not a required gate. + +## Desktop artifacts + +Local artifact builds are unsigned by default and write to `release/`: + +```sh +vp run dist:desktop:dmg +vp run dist:desktop:linux +vp run dist:desktop:win +``` + +DMGs default to the host architecture. Use `--arch` to choose another target and `--keep-stage` +to retain packaging files for inspection. Run `vp run dist:desktop:artifact --help` for other +options. + +### Linux AppImage prerequisites + +Build on Linux because the browser-secret helper links against the host's libsecret. Install +Rust, C/C++ build tools, libsecret development headers, pkg-config, and ImageMagick. + +Ubuntu and Debian: + +```sh +sudo apt-get update +sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick +``` + +Fedora: + +```sh +sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick +``` + +Arch Linux: + +```sh +sudo pacman -S rust base-devel libsecret pkgconf imagemagick +``` + +The C toolchain, pkg-config, and libsecret headers are also needed for Linux desktop development. + +### macOS DMG prerequisites + +Install the Xcode Command Line Tools with `xcode-select --install` and install Rust. +For a cross-architecture or universal build, add the requested Rust targets: + +```sh +rustup target add aarch64-apple-darwin x86_64-apple-darwin +``` + +### Windows installer prerequisites + +Install Rust, Python 3, and Visual Studio Build Tools with **Desktop development with C++**. +Include the Windows SDK and the MSVC build tools and Spectre-mitigated libraries for the target +architecture. Add its Rust target: + +```powershell +rustup target add x86_64-pc-windows-msvc +# For an ARM64 installer: +rustup target add aarch64-pc-windows-msvc +``` + +NSIS is downloaded by electron-builder. WSL support additionally needs a Linux node-pty prebuild; +see the [release runbook](./release.md#windows-payload-topology-and-update-validation). + +### Signing and passkeys + +Add `--signed` after configuring the platform credentials in the +[release runbook](./release.md). macOS passkeys need a signed, provisioned app; follow the +[Connect setup](./connect-setup.md#desktop-passkeys) for local signing and renderer HMR. diff --git a/docs/operations/release.md b/docs/operations/release.md index e5a4d3544c35..02f453ab36a5 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -329,7 +329,7 @@ Checklist: - `APPLE_API_KEY`: contents of the downloaded `.p8` - `APPLE_API_KEY_ID`: Key ID - `APPLE_API_ISSUER`: Issuer ID -10. Complete the Clerk Native API and AASA setup in [T3 Connect Clerk Setup](../internals/t3-connect.md#desktop-passkeys). +10. Complete the Clerk Native API and AASA setup in [T3 Connect setup](./connect-setup.md#desktop-passkeys). 11. Re-run a tag release and confirm macOS artifacts are signed/notarized and contain the expected `com.apple.developer.associated-domains` entitlement. diff --git a/infra/relay/README.md b/infra/relay/README.md index 0085c9c5b6b7..37b1a631970d 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -9,7 +9,7 @@ optional mobile notifications and Live Activities. The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects, regular API and WebSocket traffic goes directly between that client and the selected environment. -See the [T3 Connect architecture overview](../../docs/internals/t3-code-connect-auth-flow.html) for the larger system +See the [T3 Connect architecture note](../../docs/internals/t3-connect.md) for the larger system design. ## Responsibilities @@ -159,8 +159,6 @@ and hosted web builds. See: -- [T3 Connect Clerk Setup](../../docs/internals/t3-connect.md) for Clerk keys, JWT templates, and sign-up restrictions - setup. +- [T3 Connect setup](../../docs/operations/connect-setup.md) for Clerk keys, JWT templates, and sign-up restrictions. - [Relay Observability](../../docs/operations/relay-observability.md) for deployment tracing and diagnostics. -- [T3 Connect Architecture Overview](../../docs/internals/t3-code-connect-auth-flow.html) for the full link, - connect, endpoint, and notification flows. +- [T3 Connect architecture](../../docs/internals/t3-connect.md) for environment linking and trust boundaries. diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 3e40d94dc69a..32b090f7a83b 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -348,7 +348,7 @@ export class LinuxDesktopBuildPrerequisitesMissingError extends Schema.TaggedErr ? ["Add the requested Rust target with:", ` rustup target add ${this.rustTarget}`] : []), "", - "For other distributions, see docs/internals/scripts.md#linux-appimage-prerequisites.", + "For other distributions, see docs/operations/development.md#linux-appimage-prerequisites.", "Then rerun `vp run dist:desktop:linux`.", ].join("\n"); } @@ -420,7 +420,7 @@ export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedE "Install Rust from https://rustup.rs and add the requested target:", ` rustup target add ${this.rustTarget}`, "Install Python 3 and the Visual Studio Build Tools components listed in", - "docs/internals/scripts.md#windows-installer-prerequisites.", + "docs/operations/development.md#windows-installer-prerequisites.", "", "Then rerun the desktop artifact command.", ].join("\n");