History

docs: keep internal guides focused on architecture (#9755)

Julius Marminge committed 2 months ago

Commit 2e688a51 parent 13427ec31 files · +1104 −4469Browse files

Unified · Split

31 files
  1. AGENTS.md +13 −2
  2. CONTRIBUTING.md +5 −1
  3. apps/mobile/README.md +6 −6
  4. docs/README.md +14 −7
  5. docs/architecture/terminal-renderers.md +0 −42
  6. docs/internals/assistant-citations.md +27 −133
  7. docs/internals/ci.md +0 −44
  8. docs/internals/connection-runtime.md +60 −211
  9. docs/internals/environment-auth.md +60 −197
  10. docs/internals/glossary.md +45 −209
  11. docs/internals/mobile-development.md +23 −48
  12. docs/internals/mobile-navigation.md +51 −133
  13. docs/internals/model-manifest.md +23 −40
  14. docs/internals/overview.md +65 −177
  15. docs/internals/product-analytics.md +47 −175
  16. docs/internals/providers.md +88 −395
  17. docs/internals/remote.md +59 −256
  18. docs/internals/resource-telemetry.md +50 −394
  19. docs/internals/scripts.md +0 −215
  20. docs/internals/server-updates.md +57 −107
  21. docs/internals/t3-code-connect-auth-flow.html +0 −1160
  22. docs/internals/t3-connect.md +79 −271
  23. docs/internals/terminal-runtime.md +42 −50
  24. docs/internals/voice-input.md +18 −100
  25. docs/internals/work-artifacts.md +0 −25
  26. docs/internals/workspace-layout.md +0 −63
  27. docs/operations/connect-setup.md +126 −0
  28. docs/operations/development.md +140 −0
  29. docs/operations/release.md +1 −1
  30. infra/relay/README.md +3 −5
  31. scripts/build-desktop-artifact.ts +2 −2
AGENTS.md+13−2
@@ -72,7 +72,7 @@ The most common defect in this repo is a change that works on the path you teste · 71 unmodified lines
7272 - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
7373 - **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.
7474 - **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real.
75−- **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`.
75+- **Docs.** Check whether the change makes existing guidance inaccurate. Apply the [documentation rules](#documentation) before adding anything.
7676
7777 ## Dev servers
7878
@@ -120,11 +120,22 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real · 41 unmodified lines
120120 - One concern per PR. If the description says "also", split it.
121121 - 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.
122122
123+## Documentation
124+
125+Most code changes do not need an internal documentation change. Agents can read the code.
126+
127+- `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.
128+- 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.
129+- 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.
130+- 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.
131+- `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.
132+- 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.
133+- `docs/operations/` holds maintainer setup, release, and debugging procedures. Keep instructions for operating an installed T3 Code server in the user guides.
134+
123135 ## Plans and work artifacts
124136
125137 - 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.
126138 - Track active maintainer work in the GitHub issue or project item that owns it. External proposals follow `CONTRIBUTING.md` and belong in Ideas discussions.
127−- 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.
128139 - 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.
129140
130141 ## How it works
CONTRIBUTING.md+5−1
@@ -2,7 +2,7 @@ · 1 unmodified line
22
33 ## Developer Setup
44
5−See the [maintainer scripts guide](docs/internals/scripts.md#first-checkout) for the initial checkout,
5+See the [development runbook](docs/operations/development.md#first-checkout) for the initial checkout,
66 development commands, tests, and platform-specific desktop packaging prerequisites.
77
88 ## Read This First
@@ -49,6 +49,10 @@ Explain exactly what changed. · 40 unmodified lines
4949
5050 Explain exactly why the change should exist.
5151
52+Follow the [documentation rules](AGENTS.md#documentation). Keep internal docs for decisions and
53+hard-to-discover constraints. Update user guides when how to use a feature changes; skip descriptions
54+of obvious controls and cosmetic changes.
55+
5256 Do not mix unrelated fixes together.
5357
5458 If the PR makes anything resembling a UI change, include clear before/after images.
apps/mobile/README.md+6−6
@@ -38,12 +38,8 @@ vp run dev:client:reset · 37 unmodified lines
3838 Run that reset once after installing or changing the Uniwind dependency patch. Cached transforms
3939 can otherwise reference its previous pnpm package path. Ordinary Metro starts still keep the cache.
4040
41−Component edits use Fast Refresh. Connection-runtime edits replace the active Effect layer through
42−a stable atom runtime, preserving navigation and existing atom subscribers. Replaced registries
43−and managed runtimes dispose their resources; the app does not force a JavaScript reload. The Uniwind patch
44−skips global style invalidation when generated styles and themes are unchanged, while real style
45−changes still refresh. See [mobile development lifecycle](../../docs/internals/mobile-development.md)
46−for the lifetime boundaries.
41+Component edits use Fast Refresh. See [mobile development lifecycle](../../docs/internals/mobile-development.md)
42+before changing runtime ownership or refresh behavior.
4743
4844 Build and run the local iOS dev client:
4945
@@ -51,6 +47,10 @@ Build and run the local iOS dev client: · 1 unmodified line
5147 vp run ios:dev
5248 ```
5349
50+After changing a native dependency patch, rerun CocoaPods before rebuilding an existing iOS
51+project. pnpm gives each patch hash a new package path; Pods can otherwise keep compiling the
52+previous directory.
53+
5454 If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the
5555 reduced-capability local build. Personal Team builds omit the widget and share extensions, push
5656 entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged.
docs/README.md+14−7
@@ -23,27 +23,34 @@ Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) · 22 unmodified lines
2323
2424 ## Working on T3 Code
2525
26−Everything below is for maintainers. Setup lives in the [root README](../README.md);
27−policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../AGENTS.md).
26+Start with the [development runbook](./operations/development.md) and
27+[contribution policy](../CONTRIBUTING.md).
28+
29+Internal notes preserve architectural decisions, constraints, and implementation traps that the
30+source alone does not explain. Most code changes do not need an internal documentation update. Follow the
31+[documentation rules](../AGENTS.md#documentation) before adding one.
2832
2933 - [Architecture overview](./internals/overview.md)
30−- [Workspace layout](./internals/workspace-layout.md)
3134 - [Glossary](./internals/glossary.md)
32−- [Scripts](./internals/scripts.md)
3335 - [Connection runtime](./internals/connection-runtime.md)
34−- [Voice input](./internals/voice-input.md)
3536 - [Providers](./internals/providers.md)
37+- [Model classification](./internals/model-manifest.md)
3638 - [Remote environments](./internals/remote.md)
3739 - [Server updates](./internals/server-updates.md)
3840 - [Resource telemetry](./internals/resource-telemetry.md)
3941 - [Product analytics](./internals/product-analytics.md)
4042 - [Environment auth](./internals/environment-auth.md)
4143 - [T3 Connect](./internals/t3-connect.md)
42−- [CI gates](./internals/ci.md)
43−- [Engineering work artifacts](./internals/work-artifacts.md)
44+- [Assistant citations](./internals/assistant-citations.md)
45+- [Mobile navigation](./internals/mobile-navigation.md)
46+- [Mobile development lifecycle](./internals/mobile-development.md)
47+- [Terminal runtime](./internals/terminal-runtime.md)
48+- [Voice input](./internals/voice-input.md)
4449
4550 ### Runbooks
4651
52+- [Development and local builds](./operations/development.md)
53+- [T3 Connect setup](./operations/connect-setup.md)
4754 - [Release](./operations/release.md)
4855 - [Observability](./operations/observability.md)
4956 - [Relay observability](./operations/relay-observability.md)
docs/architecture/terminal-renderers.md+0−42
@@ -1,42 +0,0 @@
1−# Terminal renderers
2−
3−Terminal sessions remain server-owned PTYs. Clients receive the existing raw byte stream and send
4−input and resize events over the existing terminal contracts; renderer choices never cross the
5−wire.
6−
7−## Ghostty alignment
8−
9−Android and web use the official `libghostty-vt` C ABI for parsing, terminal state, grapheme
10−boundaries, keyboard encoding, selection, and scrollback:
11−
12−- Android links the native shared library and converts render state into a compact JNI snapshot.
13−- Web loads a separately cached WebAssembly build and reads render state into a Canvas 2D surface.
14−- Both artifacts are built from the revision in
15− `native/libghostty-vt/VERSION`.
16−
17−The platform adapters deliberately own only platform behavior. Android owns its Kotlin Canvas and
18−touch integration. Web owns browser font shaping, the hidden IME textarea, clipboard and DOM input,
19−and its Canvas renderer. The web adapter also delegates application mouse encoding, word and line
20−selection, and OSC 8 hyperlink metadata to the official ABI. Browser conventions remain available:
21−holding Shift bypasses application mouse capture, and the platform link modifier opens hyperlinks.
22−React does not participate in terminal frames.
23−
24−The web runtime is singleton-scoped per browser tab so split terminals share one compiled module
25−and memory. Each visible terminal owns and frees its own terminal, render state, row iterator, cell
26−iterator, key and mouse encoder, and input event handles. Restoring captured scrollback temporarily
27−detaches the PTY callback so historical device queries cannot emit replies into the current shell.
28−
29−## Updating Ghostty
30−
31−Update and rebuild Android first, because mobile's `VERSION` file is the single source of truth for
32−the upstream pin (the upstream `LICENSE` lives beside it). Then run:
33−
34−```sh
35−pnpm --dir apps/web build:ghostty-wasm
36−```
37−
38−Commit the regenerated web `wasm` artifacts. The build embeds the pinned revision into the binary as
39−semver build metadata, and the focused web ABI test reads it back through `ghostty_build_info` and
40−compares it against mobile's `VERSION` — so the web vendor directory holds only the artifacts, drift
41−cannot hide, and there is no second pin to keep in sync. The same test enforces the artifact budget
42−and exercises repeated create/write/free cycles with multi-codepoint graphemes.
docs/internals/assistant-citations.md+27−133
@@ -1,135 +1,29 @@
11 # Assistant citations
22
3−> For maintainers. Using T3 Code? See [Quote an assistant response](../user/composer.md#quote-an-assistant-response).
4−
5−Assistant citations store a selected excerpt and its source in ordinary message text. They need
6−no database migration, citation table, or sidecar draft metadata. The saved excerpt remains
7−available even when the source response is missing or has changed.
8−
9−## Serialized form
10−
11−[`AssistantCitation`][contract] defines version 1. [`assistantCitations.ts`][shared] serializes it
12−as a Markdown link with the literal label `Assistant quote`:
13−
14−```text
15−[Assistant quote](t3-citation://v1/<environmentId>/<threadId>/<messageId>?text=<quote>&start=<start>&end=<end>&prefix=<prefix>&suffix=<suffix>&comment=<optional-user-comment>)
16−```
17−
18−The formatter percent-encodes each path segment and uses `URLSearchParams` for the query. The
19−environment, thread, and message IDs identify the source without a browser origin, so local,
20−remote, and tunnel clients share one form. Opening the source still requires access to its
21−environment.
22−
23−The parser accepts only version 1 with exactly three source IDs and one of each selector field;
24−`comment` is optional and links without one keep their original format. It rejects credentials,
25−ports, malformed encoding, and extra or duplicate query fields. IDs have a 512-character limit,
26−the quote and comment each fit in 8,000 UTF-16 code units, offsets are nonnegative safe integers
27−with `end > start`, prefix and suffix each fit in 32 code units, and the URI itself is capped at
28−160,000 characters. Malformed or unsupported links remain ordinary prompt text everywhere:
29−collectors, provider expansion, and readable fallbacks all skip them. Parsing validates the
30−payload, not the existence of its source.
31−
32−## Selection and source matching
33−
34−[`assistantTextSelection.ts`][selection] captures one native DOM range inside one assistant
35−response, including backward and paragraph selections. Empty range endpoints at neighboring blocks
36−are narrowed to the first and last selected text nodes before validating the source, so
37−triple-click selections that end at the next block still resolve. The text stream follows DOM
38−order, inserts line breaks between blocks and at `<br>`, and includes displayed code and link
39−text. Controls, hidden subtrees, CSS-generated content, and soft wrapping do not contribute.
40−
41−`text` keeps the exact selected excerpt, including line breaks and indentation. `start`, `end`,
42−`prefix`, and `suffix` use the rendered-text stream after each JavaScript whitespace run collapses
43−to one space, without trimming, in UTF-16 code units. Context boundaries avoid splitting
44−surrogate pairs.
45−
46−Resolution uses the same normalization and case-sensitive matching. It checks the saved offsets
47−against the quote and context, then searches for a context-matching occurrence if positions have
48−moved. A unique quote can still match after its context changes. Multiple context-matching
49−occurrences are ambiguous, even if one remains at the saved offsets; the resolver does not guess.
50−
51−## Composer and clients
52−
53−[`AssistantSelectionToolbar`][toolbar] offers **Cite in composer** for a selection within one
54−response and disables it above the quote limit. It shares [`selectionActions.ts`][actions] with
55−the terminal's selection menu: the observer opens actions only after a primary-button release from
56−a gesture that started inside the surface, waits out the 500 ms repeat-click window so
57−double/triple clicks can finish, anchors to the release point (or the text end for keyboard
58−selections), and dismisses on outside interaction, scrolling, or `Escape`. The toolbar registers
59−its own element with the observer so interacting with it cannot replace the captured quote.
60−
61−`ChatView` inserts the serialized link at the last composer cursor. A one-shot editor request then
62−opens the comment editor beside the cited source text, carrying the live DOM range for positioning
63−and an editing highlight; that range is transient and never becomes part of the citation.
64−`withAssistantCitationComment` trims outer whitespace and removes the field when blank; it never
65−changes the saved quote or selector. Restoring or pasting a citation does not reopen the editor.
66−
67−[`ComposerCitationNode`][node] is an inline Lexical `DecoratorNode` holding the quote and comment
68−as one atomic chip. Its label shows the comment when present, otherwise a short quote preview; the
69−node retains the full serialized source and returns it from `getTextContent()`. Comment edits
70−update the citation and serialized source together as one undoable change. The token parser,
71−clipboard handling, and cursor mapping treat citations as inline tokens alongside mentions and
72−skills, so drafts, stashes, and pastes reconstruct chips from the prompt string alone. Sent
73−messages keep the same links; `ChatMarkdown` renders valid citation URIs as read-only
74−[`AssistantCitationChip`][chip]s, and the whole chip supplies its serialized value to Markdown
75−copy so the displayed label is not duplicated as text.
76−
77−## Source navigation
78−
79−[`assistantCitationNavigation.ts`][navigation] routes to the source thread with
80−`#assistant-citation=<base64url-encoded citation URI>`; the encoding keeps router hash
81−normalization from decoding quote whitespace or source IDs. Each click adds a fresh activation ID
82−to navigation state so the same link can reveal its source again after dismissal.
83−
84−[`useAssistantCitationTarget`][target] loads earlier history (bounded at 20 pages or a repeated
85−cursor, with a warning past that), expands a folded turn, and asks the virtual list to mount the
86−source row. While positioning, the timeline disables initial end-scroll and scroll maintenance and
87−keeps the cited row mounted until measured; the activation ID doubles as the list's data version
88−so pinning rows recalculates a stationary Legend list without discarding measurements.
89−
90−[`AssistantCitationSource`][source] resolves the DOM range, smoothly scrolls to it, and pulses
91−only that text through the CSS Highlight API: two 650 ms pulses driven by a registered opacity
92−property, with the second settling into a held highlight before fading out. Navigation owns the
93−highlight's start time and completion state: range repairs keep the original clock, remounting
94−resumes only the remaining time, and an expired or dismissed highlight cannot revive. Reduced
95−motion scrolls instantly and holds the highlight without pulsing. Browsers
96−without the Highlight API temporarily select the range and remove only that owned selection.
97−User navigation or `Escape` cancels pending positioning; missing messages or unmatched text
98−produce warnings without changing the saved quote. The separate comment-editing highlight uses
99−the same range-repair observer while its editor is open.
100−
101−Desktop shares the web implementation. Mobile's `ThreadFeed` uses `renderAssistantCitationsAsText`
102−to show sent quotes as readable Markdown blockquotes with each user comment below its quote; it
103−does not create citations or navigate to sources.
104−
105−## Provider input and titles
106−
107−[`ProviderService.sendTurn`][provider] calls `expandAssistantCitationsForProvider` before routing
108−to any adapter; persisted messages keep their links. Each distinct serialized link becomes an
109−inline `[assistant-quote-N]` marker, and one `<assistant_citations>` block carries JSON entries
110−with an `id` and the complete `citation`. The block identifies `citation.text` as quoted reference
111−material, not new instructions, and `citation.comment` as the user's comment about that quote.
112−JSON encoding escapes `<`, `>`, and `&` so content cannot close the wrapper. The expansion is
113−shared by all providers; there is no per-adapter citation handling or source lookup at send time.
114−
115−The 120,000-character provider input limit applies both before and after expansion:
116−[`composerSubmission.ts`][submission] checks the larger length client-side and keeps an over-limit
117−draft editable, and the server revalidates the expanded input before dispatch. Expansion does not
118−apply to pending user-input answers.
119−
120−`assistantCitationsToPlainText` supplies readable input for title seeds, stash previews, and the
121−server's title and branch-name helpers, which see the quoted text and comment rather than an
122−encoded URI.
123−
124−[contract]: ../../packages/contracts/src/assistantCitations.ts
125−[shared]: ../../packages/shared/src/assistantCitations.ts
126−[selection]: ../../apps/web/src/lib/assistantTextSelection.ts
127−[toolbar]: ../../apps/web/src/components/chat/AssistantSelectionToolbar.tsx
128−[actions]: ../../apps/web/src/lib/selectionActions.ts
129−[node]: ../../apps/web/src/components/ComposerCitationNode.tsx
130−[chip]: ../../apps/web/src/components/chat/AssistantCitationChip.tsx
131−[navigation]: ../../apps/web/src/lib/assistantCitationNavigation.ts
132−[target]: ../../apps/web/src/components/chat/useAssistantCitationTarget.ts
133−[source]: ../../apps/web/src/components/chat/AssistantCitationSource.tsx
134−[provider]: ../../apps/server/src/provider/Layers/ProviderService.ts
135−[submission]: ../../apps/web/src/components/chat/composerSubmission.ts
3+Citations carry the selected excerpt and source identity in ordinary message text.
4+Drafts, clipboard copies, stashes, and sent messages keep that representation
5+without a separate citation store. The saved quote remains usable when its source
6+disappears or changes. The [shared format](../../packages/shared/src/assistantCitations.ts) uses
7+stable environment IDs without a browser origin, so moving between local, remote,
8+and tunnel connections does not change a citation's identity.
9+
10+Source navigation is best effort. [Text selectors](../../apps/web/src/lib/assistantTextSelection.ts)
11+refer to rendered text after whitespace normalization, measured in UTF-16 units.
12+They cannot be applied to raw Markdown. The original excerpt keeps its whitespace;
13+normalization is only for locating it. Repeated text needs an unambiguous context
14+match, even if one occurrence still sits at the saved offsets. Guessing could
15+highlight the wrong claim.
16+
17+Navigation owns the highlight's lifetime across virtual-row remounts. The source
18+row must be fetched, unfolded, mounted, and measured before quote positioning
19+takes over from normal timeline scrolling. Remounting must not revive a dismissed
20+highlight. The [navigation hash](../../apps/web/src/lib/assistantCitationNavigation.ts)
21+uses base64url because router normalization otherwise decodes whitespace and
22+source IDs inside the citation URI.
23+
24+[ProviderService](../../apps/server/src/provider/Layers/ProviderService.ts) expands
25+citations before dispatch to any provider adapter. It sends the saved excerpt
26+without looking up the source, distinguishes quoted reference material from the
27+user's comment, and leaves persisted messages in their original form. Input
28+limits apply after expansion as well as before it. A draft that fits as encoded
29+links can exceed the provider limit once expanded.
docs/internals/ci.md+0−44
@@ -1,44 +0,0 @@
1−# CI quality gates
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−[`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) runs these quality gates on pull requests
6−and pushes to `main`:
7−
8−- **Check**: `vp check` (format and lint; this repo sets `typeCheck: false` in its lint options),
9− then `vpr typecheck` for the workspace type check. The same job
10− builds the desktop pipeline (`vp run build:desktop`) and verifies the preload bundle exists and
11− uses only imports that Electron's sandbox can load. The verifier parses imports, then executes the
12− trusted artifact with controlled bridge stubs to confirm that its required APIs are callable.
13−- **Test**: `vp run test` across the workspace.
14−- **Mobile Native Static Analysis**: `vp run lint:mobile` on macOS, wrapping
15− `scripts/mobile-native-static-check.ts`. A cheap Linux **Mobile Native Changes** job gates it:
16− the macOS runner only boots when the diff touches `apps/mobile` Swift/Kotlin sources, the
17− SwiftLint/detekt/ktlint configuration, the `Brewfile`, the check script, the root `package.json`
18− that defines `lint:mobile`, or `ci.yml`. Otherwise the job is skipped, which GitHub reports as
19− success for the required check. Renames are matched on both their old and new path. The gate fails
20− open in every other case: if the changed-file list cannot be resolved, GitHub truncates it, or the
21− gate job itself fails, the lint runs.
22−- **Release Smoke**: exercises release-only workflow steps through `scripts/release-smoke.ts`, so
23− release breakage surfaces on PRs rather than at tag time.
24−
25−[`.github/workflows/windows-tests.yml`](../../.github/workflows/windows-tests.yml) is a manual
26−Windows lane (`workflow_dispatch` only) on a Blacksmith Windows 2025 runner. The suite does not
27−pass on Windows yet, so it is not a required check; it exists so the work to get there can be
28−iterated against a real Windows box without one on hand. Dispatch it with `gh workflow run
29−windows-tests.yml --ref <branch>`, optionally with `-f package=<dir>` to run one workspace package
30−and `-f files="<paths>"` to run specific test files inside it. Once it is green, fold it into
31−`ci.yml`.
32−
33−`.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`)
34−desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. It auto-enables
35−signing only when platform credentials are present. macOS passkey builds additionally require
36−`APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing.
37−Without the core signing credentials, it still releases unsigned artifacts.
38−
39−Preflight shares pnpm's lockfile verification results with the desktop build jobs through a small
40−artifact. This avoids repeating dependency checks, especially on Windows, without transferring the
41−large registry metadata cache. pnpm checks the current lockfile and policy before it reuses a result.
42−If the artifact is unavailable, installation runs the checks again.
43−
44−See [Release Checklist](../operations/release.md) for the full release/signing setup checklist.
docs/internals/connection-runtime.md+60−211
@@ -1,211 +1,60 @@
1−# Connection Runtime
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−The connection runtime is shared by web and mobile. It owns connectivity,
6−authentication, retries, transport lifetime, cached environment data, and
7−environment-scoped operations.
8−
9−Web and mobile mount this runtime once at the application root and compose it
10−identically: `apps/web/src/connection/runtime.ts` and
11−`apps/mobile/src/connection/runtime.ts` differ only in the platform layer they
12−supply. There is no legacy connection owner or supported mixed mode.
13−
14−## Composition
15−
16−[`connection/layer.ts`][layer] assembles the runtime:
17−
18−- `ConnectionResolver` ([resolver.ts][resolver]) resolves a catalog entry into a
19− prepared, authenticated endpoint for primary, bearer, relay, or SSH targets.
20−- `ConnectionDriver` ([driver.ts][driver]) prepares through the resolver, opens
21− one RPC session, and reports `preparing`, `opening`, and `synchronizing`.
22−- `RpcSessionFactory` ([rpc/session.ts][session]) performs one transport
23− attempt. It does not retry. `RpcSession` is the interface it returns,
24− exposing `client`, `initialConfig`, `ready`, `probe`, and `closed`.
25−- `EnvironmentRegistry` ([registry.ts][registry]) owns the catalog and the
26− per-environment scopes.
27−- `ConnectionOnboarding` and `RelayEnvironmentDiscovery` sit alongside the
28− registry. Startup calls `EnvironmentRegistry.start` and streams platform
29− registrations into `reconcilePlatform`.
30−
31−The registry creates one environment-scoped supervisor per environment.
32−`acquireSupervisor` serializes access per environment, reuses an existing
33−supervisor when the catalog entry is unchanged, and closes and recreates the
34−scope when it changed. `createServiceScope` builds an `EnvironmentSupervisor`
35−bound to a closeable scope and connects it; `run` and `runStream` execute caller
36−effects with that supervisor provided.
37−
38−`EnvironmentSupervisor` owns desired state, retry scheduling, and the active
39−session scope. React components do not create connections, transports, retry
40−loops, or RPC clients.
41−
42−## Connection State
43−
44−The supervisor is the only retry owner.
45−
46−1. A persisted or platform registration marks an environment as desired.
47−2. If the device is offline, the supervisor releases the active session and
48− waits for a signal without consuming retry attempts or running a timer.
49−3. When online, it asks the driver for one prepared connection and one RPC
50− session.
51−4. Transient failures retry forever with exponential backoff capped at 16
52− seconds (`RETRY_DELAYS_MS`). A connection stable for 30 seconds resets
53− accumulated backoff.
54−5. Authentication or configuration failures remain blocked until an external
55− wakeup changes the relevant input.
56−6. An involuntary session close keeps the registration and cache, then retries.
57−7. Explicit removal closes the session and deletes the registration,
58− credentials, shell cache, and thread cache.
59−
60−### Wakeups
61−
62−Wakeup handling differs by phase, in [supervisor.ts][supervisor]:
63−
64−- During establishment, `waitForEstablishmentInterrupt` consumes and **ignores**
65− plain application activation. Restarting an in-flight attempt because the app
66− came to the foreground would only delay it. The exception is
67− `application-active-reconnect`, which mobile emits after a meaningful
68− background suspension; it interrupts establishment and resets the retry
69− ladder, because the OS may have silently killed the socket underneath the
70− attempt.
71−- Credential changes interrupt establishment only for relay targets, where a new
72− credential changes what is being established.
73−- Explicit disconnect, explicit retry, and going offline interrupt establishment
74− in every case.
75−- While waiting out backoff, application activation resets the retry ladder so a
76− foregrounded app reconnects immediately instead of serving the remaining
77− delay.
78−- Once connected, `monitorConnectedLease` handles plain activation by probing
79− the existing session (`lease.session.probe`, with a shorter timeout for
80− mobile's `application-active-probe`) rather than reconnecting; a healthy
81− session survives foregrounding. `application-active-reconnect` skips the probe
82− and replaces the lease outright.
83−
84−The UI derives `available`, `offline`, `connecting`, `reconnecting`,
85−`connected`, and `error` from supervisor state plus explicit data-sync state.
86−It does not infer connection health from cached data or the existence of a
87−transport object. An environment becomes `connected` after the socket opens and
88−the initial config RPC succeeds, proving that the server is responsive. Shell
89−and thread synchronization are independent data states. A healthy RPC transport
90−with a failed shell subscription is shown as connected with a synchronization
91−error, not as a reconnect that is not actually scheduled.
92−
93−## Data Boundary
94−
95−Finite requests, durable subscriptions, and commands are separate APIs:
96−
97−- Query atoms revalidate when the RPC generation changes.
98−- Subscription atoms switch to replacement sessions.
99−- Subscription failure handling in [rpc/client.ts][client] distinguishes two
100− cases. A transport failure (`isTransportFailure`: every failure is an RPC
101− client error) ends the inner subscription without resubscribing, so the outer
102− stream waits for the supervisor to supply a replacement session. A handled
103− domain failure runs `onExpectedFailure` and, when
104− `retryExpectedFailureAfter` is set, sleeps and resubscribes on the **same**
105− session. A healthy transport is never torn down for a domain failure.
106−- Mutations resolve the current environment runtime at execution time.
107−- Shell and thread snapshots are available while offline.
108−- Sync status is explicit and independent per domain. Shell status is `empty`,
109− `cached`, `synchronizing`, or `live`, with a separate `error` field; there is
110− no `failed` status. Thread status adds `deleted`.
111−- Cached shell and thread projections are never allowed to overwrite newer live
112− data during a fast reconnect.
113−- Domain atom factories route effects through the environment registry and
114− resolve the current scoped service at execution time. Project and thread
115− commands are Atom factories under `src/state`
116− (`createProjectEnvironmentAtoms`, `createThreadEnvironmentAtoms`), as are the
117− shell and thread state factories (`createEnvironmentShellAtoms`,
118− `createEnvironmentThreadStateAtoms`).
119−- Web and mobile own their Atom runtimes, React hooks, and feature composition.
120−
121−The Promise bridge exists only at the React/Atom boundary. Runtime and business
122−logic remain Effect-native.
123−
124−## Platform Layers
125−
126−Web and mobile provide:
127−
128−- network status and network-change streams;
129−- application lifecycle wakeups;
130−- cloud session credentials;
131−- device identity;
132−- platform registrations;
133−- persistent catalog, credential, shell, and thread stores;
134−- HTTP, crypto, and telemetry layers.
135−
136−Platform layers adapt operating-system capabilities. They do not implement
137−connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on
138−removal the registry clears its cache and calls the platform implementation, so
139−web clears composer drafts and mobile clears drafts plus the thread outbox.
140−
141−Mobile cloud sign-out first saves relay drafts and queued messages in the local
142−composer store under the owning account. These saved copies retain attachment
143−files during cleanup and remain outside the active composer and upload queue.
144−Signing back into that account restores them before relay credentials activate.
145−Directly paired environments keep their drafts and outbox when cloud sign-out runs.
146−
147−Mobile composer attachments upload over HTTP while their environment is connected,
148−with at most three concurrent transfers. Drafts retain local image data or an owned
149−file URI alongside the pending upload ID. Sending verifies and reuses that ID, or
150−uploads the local bytes again if it expired. Disconnecting cancels active transfers
151−without discarding drafts; reconnecting resumes preparation. Older servers without
152−attachment-upload support continue to receive inline images.
153−
154−## Source Boundaries
155−
156−Applications must import explicit package subpaths; the package intentionally
157−has no root export. The subpaths are documented in
158−[packages/client-runtime/README.md](../../packages/client-runtime/README.md),
159−with the `exports` map in that package's `package.json` as the authoritative
160−list. Files that are not exported are implementation details.
161−
162−## Application Boundary
163−
164−The application root mounts the shared connection layer, creates its own Atom
165−runtime, and selects the domain atom factories required by that platform. Web
166−and mobile may expose different hooks and features without changing connection
167−ownership.
168−
169−Application code must not construct RPC clients, retry loops, or raw
170−orchestration commands. Persistence paths belong to the platform registration
171−and cache stores, with explicit migration or invalidation policy.
172−
173−### Thread detail ownership
174−
175−Mounted detail consumers share one live thread subscription. The subscription
176−ends when the last consumer leaves. Hidden routes that remain mounted still
177−count as consumers.
178−
179−A separate registry-local atom retains the last thread state and replay cursor
180−for five idle minutes. A warm mount renders that state and resumes the stream
181−without another snapshot download. The cache holds completed state and cursor
182−updates together. It retains pagination data but clears canceled loading state.
183−An ownership token prevents an old scope from replacing its successor's cache.
184−Closing a thread skips snapshots that were already saved. Changed snapshots and
185−failed background writes still get a final save attempt.
186−
187−## Verification
188−
189−Core state-machine tests use `@effect/vitest` and deterministic service layers.
190−Required coverage includes:
191−
192−- offline startup and online wakeup;
193−- forever retry with the 16-second cap;
194−- explicit retry interrupting backoff;
195−- authentication wakeups;
196−- involuntary close and reconnect;
197−- explicit removal clearing all owned state;
198−- relay token reuse and refresh;
199−- progressive relay discovery;
200−- shell and thread cache hydration;
201−- thread stream shutdown, warm resume, and snapshot expiry;
202−- durable subscriptions switching sessions;
203−- command metadata and idempotent queued-command metadata.
204−
205−[layer]: ../../packages/client-runtime/src/connection/layer.ts
206−[resolver]: ../../packages/client-runtime/src/connection/resolver.ts
207−[driver]: ../../packages/client-runtime/src/connection/driver.ts
208−[registry]: ../../packages/client-runtime/src/connection/registry.ts
209−[supervisor]: ../../packages/client-runtime/src/connection/supervisor.ts
210−[session]: ../../packages/client-runtime/src/rpc/session.ts
211−[client]: ../../packages/client-runtime/src/rpc/client.ts
1+# Connection runtime
2+
3+Web, the desktop renderer, and mobile share one connection owner per environment
4+in `packages/client-runtime`. Platform code supplies storage, credentials, network
5+signals, and application lifecycle events. React views consume the runtime.
6+Keeping retries and session lifetime here prevents competing reconnect loops when
7+several views need the same environment.
8+
9+## One retry owner
10+
11+The [supervisor](../../packages/client-runtime/src/connection/supervisor.ts) owns
12+retry policy; resolving an endpoint and opening an RPC session are single
13+attempts. Transient failures retry with capped backoff. Offline states and
14+authentication failures wait for a wakeup instead of spending attempts on
15+unchanged conditions.
16+
17+Foregrounding needs different treatment depending on the connection's state.
18+It wakes a retry immediately, leaves an ordinary in-flight attempt alone, and
19+probes an established session before replacing it. A long mobile background
20+suspension forces replacement because the OS can kill a socket without reporting
21+closure. Treating every foreground event as a reconnect delays healthy attempts;
22+treating every resume as harmless leaves suspended sockets stuck.
23+
24+The [registry](../../packages/client-runtime/src/connection/registry.ts) scopes
25+connections by environment. An involuntary disconnect retains the registration
26+and cached data. Explicit removal closes the scope and clears credentials,
27+projections, and platform-owned state such as drafts. Cloud-account changes apply
28+to relay registrations; they must not discard directly paired environments.
29+
30+## Transport health and data freshness are separate
31+
32+A socket opening is insufficient evidence that the environment is usable. The
33+[RPC session](../../packages/client-runtime/src/rpc/session.ts) waits for the
34+initial server configuration before becoming ready. Shell and thread data then
35+have their own synchronization state. A failed shell subscription can coexist
36+with a healthy connection; labeling that state "reconnecting" promises a
37+transport retry that will never happen.
38+
39+Cached projections remain readable offline. They must neither imply a live
40+connection nor overwrite newer live data during a reconnect. Loading and
41+resuming snapshots belongs to the shared state services, so every view agrees
42+on which data is current.
43+
44+[Thread detail](../../packages/client-runtime/src/state/threads.ts) separates
45+subscription lifetime from cache lifetime. Mounted consumers share one live
46+stream, which stops when the last consumer unmounts; hidden mounted routes still
47+count. A registry-local cache retains state and its replay cursor for five idle
48+minutes so back navigation can resume without another snapshot download.
49+
50+Retain state and cursor together only after an update finishes. Cancellation must
51+not advance the cached cursor beyond the applied data, and an old scope must not
52+overwrite its successor's cache. Preserve pagination data on reuse, but clear
53+canceled loading state.
54+
55+The [RPC boundary](../../packages/client-runtime/src/rpc/client.ts) resolves
56+requests against the current session at execution time. Durable subscriptions
57+follow replacement sessions. After a transport failure they wait for the
58+supervisor; an expected domain failure may resubscribe on the same healthy
59+session. Reconnection does not automatically replay mutations, whose retry and
60+idempotency rules belong to the operation.
docs/internals/environment-auth.md+60−197
@@ -1,197 +1,60 @@
1−# Environment Authentication Profile
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−The environment server and the relay use separate credentials, issuers, and trust
6−boundaries. They intentionally use a similar OAuth-shaped model so that permission
7−checks and token exchange behavior can be audited against established concepts.
8−
9−## Authorization Model
10−
11−Environment authorization is capability-based. A session carries zero or more
12−OAuth-style scope strings:
13−
14−| Scope | Permission |
15−| ----------------------- | ------------------------------------------------------------------------ |
16−| `orchestration:read` | Read snapshots, status, events, configuration, and filesystem/VCS state. |
17−| `orchestration:operate` | Dispatch user operations and mutate environment-side workspace state. |
18−| `terminal:operate` | Create, attach, input, resize, clear, restart, and terminate terminals. |
19−| `review:write` | Read review diff previews used to compose review feedback. |
20−| `access:read` | Inspect pairing links and client sessions. |
21−| `access:write` | Create or revoke pairing links and client sessions. |
22−| `relay:read` | Inspect managed relay connectivity. |
23−| `relay:write` | Link, configure, or unlink managed relay connectivity. |
24−
25−Pairing-link lists and access-stream snapshots and updates contain metadata only.
26−The raw credential is returned only by the creation request, after the server checks
27−`access:write` and the delegated scopes. Web and desktop clients keep that response
28−in memory for sharing. They do not recover credentials from access read models.
29−
30−Ordinary pairing links grant the four client-operation scopes and read access to
31−managed relay connectivity:
32−`orchestration:read orchestration:operate terminal:operate review:write relay:read`.
33−The desktop bootstrap credential and command-line administrative bootstrap
34−credentials additionally grant `access:read access:write relay:write`.
35−
36−## Host file access
37−
38−Clients with `orchestration:read` can read files anywhere the environment's server account can
39−read, following the environment-wide authorization model rather than introducing per-project
40−filesystem permissions. `projects.readFile` accepts an absolute path and returns the text of that
41−host file; only workspace-relative paths pass its root check, and `projects.writeFile` never
42−accepts an absolute path. Clients use this to show files an agent wrote outside the workspace, such
43−as a report in a temp directory, read-only.
44−
45−## Media preview access
46−
47−Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for
48−supported images, videos, HTML, and PDF files anywhere the environment's server account can read.
49−A thread ID supplies the workspace for relative paths; absolute paths refer to the environment
50−host, not the client.
51−
52−[`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular
53−file, and validates the resolved file's literal extension. It opens the file and signs its canonical
54−path and device/inode identity for one hour. The token grants access to that exact file, not adjacent
55−files or its containing directory. Serving rechecks the canonical path, media type, and opened
56−descriptor's identity, then streams full or partial responses from that descriptor. Replacing a
57−file atomically requires a freshly signed URL; editing it in place does not. Because the token names
58−one file, an HTML document served this way cannot load sibling assets; the directory-scoped
59−`workspace-file` resource remains the route for HTML inside the workspace. Uploaded attachments keep
60−their separate asset resource.
61−
62−Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment
63−can fetch that file until it expires. Clients should copy the authored reference, not the temporary
64−URL. Responses use `nosniff`; SVG responses retain their restrictive sandbox policy. Video reads
65−support byte ranges so playback does not require a complete download first.
66−
67−Host videos can change in place, so their responses use `private, no-store` and omit `ETag`
68−and `Last-Modified`. File metadata cannot prove byte-for-byte identity for `If-Range`; advertising
69−those validators would encourage native players to send conditional seeks that require a full
70−response. Ordinary range requests receive partial responses. An explicitly supplied `If-Range`
71−still falls back to a full response because no strong validator is available. Host image previews
72−keep their private cache policy and weak metadata validators.
73−
74−The server serves media in place without importing it into attachment storage. Deletion makes
75−future server reads fail, though an already loaded client or its cache can retain bytes. Native
76−viewers may use temporary client-side files for display or explicit sharing; those are not durable
77−environment copies.
78−
79−## Authentication Flows
80−
81−### Browser Session
82−
83−`POST /api/auth/browser-session` consumes a one-time bootstrap credential and creates a
84−browser session cookie. The cookie is an HTTP transport adapter for the same
85−scoped session model; the response never exposes the session secret to browser
86−JavaScript.
87−
88−### Bearer Access Token
89−
90−Non-browser clients use `POST /oauth/token` with an
91−`application/x-www-form-urlencoded` body:
92−
93−```text
94−grant_type=urn:ietf:params:oauth:grant-type:token-exchange
95−subject_token=<bootstrap credential>
96−subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap
97−requested_token_type=urn:ietf:params:oauth:token-type:access_token
98−scope=orchestration:read orchestration:operate terminal:operate review:write relay:read
99−```
100−
101−Clients may additionally submit `client_label`, `client_device_type`, and
102−`client_os` extension parameters so the authorized-clients UI can identify the
103−device that established the session. These are presentation hints only; the
104−environment derives transport metadata such as IP address and user agent from
105−the request and does not use these fields for authorization.
106−
107−The response has the token-exchange shape:
108−
109−```json
110−{
111− "access_token": "<opaque session token>",
112− "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
113− "token_type": "Bearer",
114− "expires_in": 2592000,
115− "scope": "orchestration:read orchestration:operate terminal:operate review:write relay:read"
116−}
117−```
118−
119−Sessions issued from a plain bearer exchange use the store's
120−`DEFAULT_SESSION_TTL` of 30 days. The shorter one-hour `expires_in: 3600` applies
121−only to DPoP-bound exchanges, where the token is additionally constrained by a
122−proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`.
123−
124−The reusable `desktop-bootstrap` grant replaces active sessions with the same
125−subject and authentication method. Revocation and insertion share one database
126−transaction, so a failed insertion preserves the previous credential. This also
127−removes stale local desktop entries from earlier launches. Browser-cookie sessions
128−and sessions issued through pairing links are not replaced.
129−
130−Requested scopes must be a subset of the one-time bootstrap credential grant.
131−An ordinary paired client therefore cannot exchange its grant for
132−`access:read`, `access:write`, or `relay:write`.
133−
134−### DPoP-Bound Access Token
135−
136−The same `/oauth/token` exchange supports proof-of-possession tokens. A client
137−that sends a `DPoP` header has its proof verified by `verifyRequestDpopProof`;
138−the resulting JWK thumbprint is stored on the session, which is then issued with
139−method `dpop-access-token` and a one-hour TTL instead of the bearer default. An
140−invalid proof gets a DPoP challenge header and a credential error rather than a
141−bearer token. Newer servers include a safe `dpopFailureReason` category in that
142−error. When an older server omits the category, clients mention clock skew as
143−one possible cause rather than presenting it as confirmed.
144−
145−`dpop-access-token` is advertised alongside `browser-session-cookie` and
146−`bearer-access-token` in the descriptor's `sessionMethods`
147−(`EnvironmentAuthPolicy.ts`), so clients can discover support rather than
148−assume it. Relay-brokered clients use this mode so that a leaked token cannot be
149−replayed without the corresponding key.
150−
151−### WebSocket Ticket
152−
153−`POST /api/auth/websocket-ticket` accepts any authenticated session and returns
154−a short-lived, single-purpose WebSocket ticket, issued through
155−`EnvironmentAuth.issueWebSocketTicket` with a five-minute default TTL. The
156−client presents its bearer or DPoP credential in headers to get the ticket, then
157−appends only that ticket to the socket URL as `wsTicket`. This keeps long-lived
158−tokens and browser cookies out of WebSocket URLs while letting the handshake
159−authenticate.
160−
161−The ticket carries its session's scopes; each RPC method then enforces
162−`orchestration:read`, `orchestration:operate`, `terminal:operate`,
163−`review:write`, `relay:write`, or `access:read` as appropriate, through
164−`RPC_REQUIRED_SCOPES` in `apps/server/src/auth/RpcAuthorization.ts`. Review feedback submission currently dispatches
165−an orchestration operation, so clients performing it also need
166−`orchestration:operate`. Creating a ticket is not authorization to call every
167−RPC method.
168−
169−## Standards Alignment
170−
171−- Bearer access tokens are used through the `Authorization: Bearer` scheme from
172− RFC 6750.
173−- The token endpoint profiles the request and response vocabulary from OAuth 2.0
174− Token Exchange (RFC 8693), including `subject_token`, `requested_token_type`,
175− `access_token`, `issued_token_type`, and `token_type`.
176−- Scope values follow the OAuth 2.0 scope model from RFC 6749: space-delimited,
177− unordered capabilities with subset checking during exchange.
178−
179−This is intentionally not a general-purpose OAuth authorization server. The
180−environment bootstrap token type is private, the bootstrap cookie and WebSocket
181−connection-token routes are product-specific adapters, and the API returns its
182−typed `HttpApi` errors rather than implementing every OAuth error response
183−surface.
184−
185−## Upgrade Behavior
186−
187−Migration `031_AuthAuthorizationScopes` is a hard cutover from role-bearing auth
188−records to scoped records. It deletes existing pairing links and sessions while
189−leaving non-authentication environment state unchanged. Upgraded clients must
190−pair again; old `owner` or `client` credentials are never silently mapped to new
191−capabilities.
192−
193−## Relay Boundary
194−
195−Relay-managed tunnels use their own tokens and keys. The relay can reuse scope
196−parsing and token-exchange conventions, but an environment access token is not a
197−relay token and cannot be presented to the relay.
1+# Environment authentication
2+
3+The environment issues its own sessions and enforces their capabilities. Cloud
4+identity and relay credentials belong to a separate trust boundary, described in
5+[T3 Connect](./t3-connect.md). A relay token is never an environment login.
6+
7+## Authority survives transport changes
8+
9+Pairing delegates a set of scopes. Exchanging a bootstrap credential can narrow
10+that grant but cannot widen it. Ordinary pairing does not grant access-management
11+or relay-management authority. Creating another pairing link requires both
12+`access:write` and every scope being delegated. The
13+[auth handlers](../../apps/server/src/auth/http.ts) enforce this at issuance;
14+client labels and device metadata have no authorization role.
15+
16+The access read model contains pairing metadata, never recoverable pairing
17+secrets. Only the creation response returns the raw credential. Otherwise read
18+access to the connections list would become a way to acquire another client's
19+authority.
20+
21+Browser cookies, bearer tokens, and DPoP tokens adapt the same scoped session
22+model. DPoP binds a token to a client's proof key; an invalid proof must fail
23+rather than fall back to bearer authentication. The OAuth token-exchange
24+vocabulary gives these grants a familiar meaning, but the environment does not
25+implement a general-purpose OAuth authorization server.
26+
27+Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticated
28+HTTP so long-lived tokens stay out of socket URLs. Browser sessions can
29+authenticate the upgrade with their cookie. A successful handshake grants no
30+extra authority: [every RPC declares a required
31+scope](../../apps/server/src/auth/RpcAuthorization.ts).
32+
33+Desktop restarts forget the previous local bearer token, so its reusable
34+bootstrap grant replaces earlier sessions for the same subject and method.
35+Revocation and insertion share a [database
36+transaction](../../apps/server/src/persistence/AuthSessions.ts); a failed
37+replacement must leave the old credential usable. Pairing and browser sessions
38+do not follow this replacement rule.
39+
40+## The environment is the filesystem boundary
41+
42+Projects are organizational boundaries, not filesystem sandboxes.
43+`orchestration:read` permits reading files the server account can read, including
44+absolute paths outside a project. This lets clients display artifacts that an
45+agent writes in a temporary directory. Relative paths and writes still follow
46+the [workspace path rules](../../apps/server/src/workspace/WorkspaceFileSystem.ts).
47+
48+Signed asset URLs are bearer credentials. A URL for media on the host grants
49+access to one canonical file and its device/inode identity, not its containing directory.
50+[Asset access](../../apps/server/src/assets/AssetAccess.ts) rechecks the opened
51+file's identity when serving it, so atomic replacement requires a new URL while
52+editing the same file in place does not. An HTML file authorized this way cannot
53+load sibling assets; directory-scoped workspace previews are a separate grant.
54+Clients should share the authored file reference so they do not disclose the
55+temporary URL's credential.
56+
57+Host videos can change in place. Their [HTTP
58+responses](../../apps/server/src/http.ts) omit cache validators because file
59+metadata cannot prove byte-for-byte identity for `If-Range`. Adding weak
60+validators would turn native-player seeks into full downloads.
docs/internals/glossary.md+45−209
@@ -1,211 +1,47 @@
11 # Glossary
22
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−This is a living glossary for T3 Code. It explains what common terms mean in this codebase.
6−
7−## Table of contents
8−
9−- [Project and workspace](#project-and-workspace)
10−- [Thread timeline](#thread-timeline)
11−- [Orchestration](#orchestration)
12−- [Provider runtime](#provider-runtime)
13−- [Checkpointing](#checkpointing)
14−- [Appearance](#appearance)
15−
16−## Concepts
17−
18−### Project and workspace
19−
20−#### Project
21−
22−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].
23−
24−#### Workspace root
25−
26−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].
27−
28−#### Worktree
29−
30−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].
31−
32−### Thread timeline
33−
34−#### Thread
35−
36−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].
37−
38−#### Turn
39−
40−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].
41−
42−#### Activity
43−
44−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].
45−
46−### Orchestration
47−
48−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].
49−
50−#### Aggregate
51−
52−The domain object a command or event belongs to. In [the contracts][1], that is usually `project` or `thread`. See [decider.ts][8].
53−
54−#### Command
55−
56−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].
57−Examples include `thread.create`, `thread.turn.start`, and `thread.checkpoint.revert`.
58−
59−#### Domain Event
60−
61−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.
62−Examples include `thread.created`, `thread.message-sent`, and `thread.turn-diff-completed`.
63−
64−#### Decider
65−
66−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].
67−
68−#### Projection
69−
70−A read-optimized view derived from events. See [projector.ts][4], [ProjectionPipeline.ts][11], and [ProjectionSnapshotQuery.ts][10].
71−
72−#### Projector
73−
74−The logic that applies domain events to the read model or projection tables. See [projector.ts][4] and [ProjectionPipeline.ts][11].
75−
76−#### Read model
77−
78−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].
79−
80−#### Reactor
81−
82−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].
83−
84−#### Receipt
85−
86−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].
87−
88−#### Quiesced
89−
90−"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.
91−
92−### Provider runtime
93−
94−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].
95−
96−#### Provider
97−
98−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.
99−
100−#### Session
101−
102−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].
103−
104−#### Runtime mode
105−
106−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].
107−
108−#### Interaction mode
109−
110−The agent interaction style for a thread. In [the contracts][1], the values are `default` and `plan`.
111−
112−#### Assistant delivery mode
113−
114−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].
115−
116−#### Snapshot
117−
118−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].
119−
120−#### Usage limits
121−
122−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).
123−
124−#### Usage limit source
125−
126−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).
127−
128−#### Model manifest
129−
130−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.
131−
132−### Checkpointing
133−
134−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].
135−
136−#### Checkpoint
137−
138−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].
139−
140−#### Checkpoint ref
141−
142−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].
143−
144−#### Checkpoint baseline
145−
146−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].
147−
148−#### Checkpoint diff
149−
150−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].
151−
152−#### Turn diff
153−
154−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].
155−
156−### Appearance
157−
158−#### Environment theme
159−
160−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].
161−
162−#### Default theme
163−
164−The environment's theme, held in its `settings.json` as `defaultTheme` (with `defaultThemeSetAt`
165−as the set-generation) and set with `t3 theme set <id>`. Web and desktop clients apply each set
166−once — live when connected, on the next connect otherwise — so setting it switches them, while a
167−theme a user picks in Settings afterwards sticks until the next set; mobile keeps its own
168−appearance settings. Naming a published [environment theme](#environment-theme) is how a desktop
169−ships T3 Code already matching it.
170−
171−## Practical Shortcuts
172−
173−- If you see `requested`, think "intent recorded".
174−- If you see `completed`, think "result applied".
175−- If you see `receipt`, think "async milestone signal, for tests".
176−- If you see `checkpoint`, think "workspace snapshot for diff/restore".
177−- If you see `quiesced`, think "all relevant follow-up work has gone idle".
178−
179−## Related Docs
180−
181−- [Architecture overview][24]
182−- [Provider architecture][16]
183−- [Permission modes][18]
184−- [Workspace layout][2]
185−
186−[1]: ../../packages/contracts/src/orchestration.ts
187−[2]: ./workspace-layout.md
188−[3]: ../../apps/server/src/vcs/GitVcsDriverCore.ts
189−[4]: ../../apps/server/src/orchestration/projector.ts
190−[5]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
191−[6]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts
192−[7]: ../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts
193−[8]: ../../apps/server/src/orchestration/decider.ts
194−[9]: ../../apps/server/src/orchestration/commandInvariants.ts
195−[10]: ../../apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
196−[11]: ../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts
197−[12]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
198−[13]: ../../apps/server/src/orchestration/Services/RuntimeReceiptBus.ts
199−[14]: ../../apps/server/src/provider/Layers/ProviderService.ts
200−[15]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
201−[16]: ./providers.md
202−[17]: ../../apps/server/src/provider/Layers/CodexAdapter.ts
203−[18]: ../user/permission-modes.md
204−[19]: ../../apps/server/src/checkpointing/CheckpointStore.ts
205−[20]: ../../apps/server/src/checkpointing/CheckpointDiffQuery.ts
206−[21]: ../../apps/server/src/persistence/Services/ProjectionCheckpoints.ts
207−[22]: ../../apps/server/src/checkpointing/Utils.ts
208−[23]: ../../apps/server/src/checkpointing/Diffs.ts
209−[24]: ./overview.md
210−[25]: ../../apps/server/src/environmentTheme.ts
211−[26]: ../user/environment-theme.md
3+Terms whose meaning matters across T3 Code. Architecture and lifecycle constraints belong in the
4+[overview](./overview.md), not in these definitions.
5+
6+## Workspace and conversation
7+
8+| Term | Meaning |
9+| -------------- | ------------------------------------------------------------------------------------------------- |
10+| Environment | One running server and the machine, credentials, workspace access, and state it owns. |
11+| Client | A web, desktop, or mobile UI connected to an environment. The desktop app can also host a server. |
12+| Project | An environment-local workspace record rooted at a directory. |
13+| Workspace root | The project's base filesystem directory on the environment. |
14+| Worktree | A separate Git checkout a thread can use instead of the project's main checkout. |
15+| Thread | The durable conversation and work history for a project. It survives provider process exits. |
16+| Turn | One user-to-agent work cycle. Provider work can finish before checkpoint and diff work settles. |
17+| Activity | A non-message timeline item, such as a tool action, approval, or failure. |
18+| T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. |
19+
20+## Orchestration
21+
22+| Term | Meaning |
23+| ----------------------- | -------------------------------------------------------------------------------------------- |
24+| Command | A request to change domain state. Accepting it does not mean its side effects have finished. |
25+| Event | A persisted fact produced by a command. |
26+| Decider | The pure logic that turns a command and current state into events. |
27+| Projection / read model | A view of current state derived from persisted events. |
28+| Projector | The logic that applies events to a read model. |
29+| Reactor | A worker that performs follow-up work in response to recorded intent or runtime signals. |
30+| Command receipt | A durable record of a command's result, used to make retries idempotent. |
31+| Runtime receipt | A test-only signal that an asynchronous milestone completed. |
32+| Quiesced | The relevant follow-up workers have finished, beyond the provider turn merely ending. |
33+
34+## Providers and checkpoints
35+
36+| Term | Meaning |
37+| ------------------- | ------------------------------------------------------------------------------------------------------------ |
38+| Provider | The agent runtime T3 Code controls, such as Codex or Claude Code. |
39+| Driver | The integration for a provider kind. |
40+| Provider instance | One configured provider, with its own settings and lifecycle. Multiple instances can use the same driver. |
41+| Adapter | The boundary translating a provider's native protocol into T3 Code operations and events. |
42+| Session | The provider runtime attached to a thread. A session can be stopped and resumed without deleting the thread. |
43+| Runtime mode | The thread's permission policy. See [permission modes](../user/permission-modes.md). |
44+| Interaction mode | How the agent approaches the task, such as planning. Separate from permission policy. |
45+| Checkpoint | A saved workspace state used for diffs and restore, stored as a hidden Git ref. |
46+| Checkpoint baseline | The workspace state captured before the work being compared. |
47+| Turn diff | The workspace changes attributed to one turn. |
docs/internals/mobile-development.md+23−48
@@ -1,50 +1,25 @@
11 # Mobile development lifecycle
22
3−Ordinary component changes use Metro Fast Refresh. The connection runtime uses
4−a stable development-only atom runtime whose writable layer atom receives the
5−newly evaluated Effect layer. Its module accepts the update only after installing
6−that layer. Existing subscribers observe the new context, and Effect releases the
7−previous runtime scope without restarting React Navigation. Production uses an
8−ordinary `Atom.runtime` without this hot-update boundary.
9−
10−Replacing the atom registry itself disposes its old nodes; replacing the managed
11−runtime starts its asynchronous disposal. These modules use normal Metro update
12−propagation. The app does not call `DevSettings.reload` during their replacement.
13−React can still reset state when its normal Fast Refresh rules require it.
14−
15−Do not reset the shared registry to clean up a connection-runtime edit: registry
16−reset removes listeners from unedited mounted consumers. Do not self-accept a
17−runtime module while leaving importers attached to its old implementation. A
18−hot-update boundary must install fresh behavior through the existing reactive
19−dependencies. These boundaries do not make every module-level atom family in the
20−app hot-swappable; new runtime singletons still need explicit ownership. Editing
21−the registry or managed runtime can still rebuild a much larger dependency graph
22−than an ordinary component or connection-runtime edit.
23−
24−Environment supervisor scopes are children of the connection registry scope.
25−The registry's per-environment map supports targeted shutdown, but it cannot be
26−the sole owner: a supervisor created after that map's finalizer has run must
27−still inherit the closed parent scope. Otherwise interrupted startup or runtime
28−replacement can leave a session and WebSocket alive outside the current registry.
29−
30−The compact Home list owns its minute-based presentation clock in a focus effect.
31−Blur clears the interval without a render-driving focus subscription; focus
32−refreshes the clock immediately. Other prop or state changes can still render
33−the hidden list. Exact snooze-expiry timers remain active, and the visible iPad
34−sidebar keeps its own minute updates.
35−
36−Connection and runtime projections are shared per environment. Thread selection
37−consumers should read those atoms instead of repeatedly parsing the same socket
38−URL or constructing new connection objects during each render.
39−
40−The Uniwind dependency patch still recompiles CSS on Metro updates so newly used
41−classes are discovered. It fingerprints the generated native stylesheet and
42−theme list, then skips development-only global invalidation when that output is
43−unchanged. A changed stylesheet or theme list still resets the style caches and
44−notifies subscribers. The digest is committed only after initialization succeeds.
45−Web and production retain their existing initialization behavior.
46−
47−After installing or changing the Uniwind patch, restart Metro once with
48−`vp run dev:client:reset` from `apps/mobile`. pnpm gives patched packages new
49−filesystem paths, and cached transforms can otherwise retain references to the
50−previous package. Ordinary development starts should retain the transform cache.
3+The [connection runtime's HMR boundary](../../apps/mobile/src/lib/hot-swappable-atom-runtime.ts)
4+keeps a stable atom runtime and replaces its Effect layer through a writable atom.
5+It accepts the update only after installing the new layer. Otherwise importers
6+retain the old behavior even though Metro reports a successful refresh.
7+
8+Do not reset the shared atom registry to refresh a connection-runtime edit. Reset
9+removes listeners from mounted consumers that Metro has no reason to rerender.
10+When the registry or managed-runtime module itself changes, normal Metro propagation
11+disposes its old resources. This boundary does not make arbitrary module-level atom
12+families safe to hot-swap. Production uses an ordinary atom runtime.
13+
14+[Environment supervisor scopes](../../packages/client-runtime/src/connection/registry.ts)
15+are children of the registry scope. The per-environment map supports targeted
16+shutdown, but a supervisor created after its cleanup runs would escape it. A closed
17+parent scope also closes late arrivals, preventing interrupted startup or runtime
18+replacement from leaving a WebSocket alive outside the new registry.
19+
20+The [Uniwind patch](../../patches/uniwind@1.11.0.patch) still compiles CSS on Metro
21+updates so newly used classes are discovered. It skips global style invalidation
22+only when the generated stylesheet and theme list are unchanged. Skipping compilation
23+would lose new classes; invalidating every consumer for unchanged output makes an
24+ordinary component edit refresh the whole app. The fingerprint is recorded only
25+after initialization succeeds.
docs/internals/mobile-navigation.md+51−133
@@ -1,138 +1,56 @@
11 # Mobile navigation
22
3−The iOS Home and thread routes share the root native stack in
4−[`Stack.tsx`](../../apps/mobile/src/Stack.tsx). Keeping them in one navigation
5−controller lets UIKit animate the header between routes. The iPad sidebar owns
6−a separate, single-screen stack; Android uses its own in-flow headers.
7−
8−Home and the iPad sidebar render their brand and connection status through
9−`headerTitle`, with `Threads` retained as the route title. The editor-style native
10−bar aligns that title on the leading side. Do not model the brand as a toolbar
11−button: on iOS 26.5 UIKit morphs a background-free custom leading item's rectangle
12−into the next screen's glass back button, even with distinct item identifiers.
13−Using the title slot lets the brand and native back button animate separately.
14−
15−The connection-status title has a maximum width based on its header's width and
16−trailing actions. A long environment name or larger text must not push Settings
17−into UIKit's overflow menu. Keep the full status as the accessibility label
18−while visually truncating it. The iPad sidebar uses its pane width, not the full
19−window width.
20−
21−On iOS 26, UIKit does not recognize Fabric's custom text views when shaping the
22−native scroll-edge fade. The screens patch gives custom title subviews an empty,
23−non-interactive native label matching their bounds. This supplies the fade's
24−geometry without drawing anything or turning the title back into a toolbar item.
25−Check the top scroll fade as well as push/pop when changing these title views.
26−
27−The react-native-screens patch caches leading, trailing, and center item groups
28−independently. A button can belong to only one group: constructing another group
29−with the same button removes it from the previous one. Unrelated menu updates
30−must therefore preserve the other groups while UIKit may be animating them.
31−
32−Each cache includes the owning header config, its item values, and custom native
33−item identities, so changed content or remounted event emitters still rebuild.
34−
35−Custom header identifiers require native code generation and a new mobile build;
36−an over-the-air JavaScript update alone is insufficient. The Android view manager
37−implements the generated identifier setter as a no-op because this behavior is
38−specific to iOS 26 and later.
39−
40−On iOS 26 the full-screen back swipe is UIKit's `interactiveContentPopGestureRecognizer`,
41−and react-native-screens makes it wait for any horizontally scrollable ScrollView to
42−fail first. Upstream applies that to every horizontal ScrollView regardless of
43−position, so a back swipe on a code block or table that is already at its leading
44−edge only bounces. The patch narrows the rule to ScrollViews with content still
45−hidden to the left; at the leading edge they yield to the pop gesture like plain
46−text does.
47−
48−After changing a dependency patch, refresh CocoaPods before rebuilding an
49−existing iOS project. pnpm installs each patch hash in a different directory;
50−an old Pods project can keep compiling the previous directory even though Metro
51−and `apps/mobile/node_modules` resolve to the new patch.
52−
53−`ControlPillMenu` resolves semantic icon colors through `withUniwind` and supplies them to every
54−iOS `MenuView` action, including nested actions. The menu library's Fabric bridge converts a missing
55−`imageColor` to transparent, so callers should use this wrapper instead of
56−rendering `MenuView` directly. Explicit colors are preserved, and destructive
57−actions default to the theme's danger foreground color. Native stack header menus
58−use a separate implementation and do not need this workaround.
3+Home and thread routes share the [root native stack](../../apps/mobile/src/Stack.tsx)
4+so UIKit can animate the header within one navigation controller. The iPad sidebar
5+has its own stack. Splitting Home and threads between controllers loses that
6+continuous transition.
7+
8+## UIKit constraints
9+
10+The [react-native-screens patch](../../patches/react-native-screens@4.26.2.patch)
11+preserves behavior that is easy to break when changing native headers:
12+
13+- The brand belongs in `headerTitle`. On iOS 26.5, UIKit morphs a background-free
14+ leading toolbar item's rectangle into the next screen's glass back button,
15+ even when the items have different identifiers.
16+- UIKit's scroll-edge fade does not recognize Fabric text views. An empty native
17+ label supplies the custom title's geometry. Removing that apparently unused
18+ view changes the fade.
19+- Leading, trailing, and center item groups need independent caches. A button can
20+ belong to only one group; rebuilding an unchanged group can pull its buttons
21+ out of a group UIKit is still animating. Cache ownership also includes the
22+ header config, whose event emitter changes on remount.
23+- A horizontal ScrollView at its leading edge must yield to the full-screen back
24+ gesture. Upstream gives every horizontal ScrollView priority, so swiping back
25+ on a code block or table can only bounce its content.
26+
27+The patch adds native header props that require code generation and a new binary.
28+Patch rebuild guidance lives in the
29+[mobile development README](../../apps/mobile/README.md#development).
5930
6031 ## Native media presentations
6132
62−`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit,
63−image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child
64−and pass the stable identifier to the presentation. The registry keeps weak
65−references to source views; recycled or compact composer thumbnails can register
66−the same identifier. Identifiers must distinguish simultaneously visible attachments.
67−The source registration does not own the preview. Android uses a regular view.
68−
69−On iOS, video previews mount `AVPlayerViewController` temporarily inside the
70−registered source and enter full screen through AVKit. AVKit
71−owns that zoom, its playback controls, Close button, and interactive dismissal.
72−Do not replace AVKit's transition with `preferredTransition`: in the iOS 27
73−simulator, that leaves native Close unable to exit full screen. When the source is unavailable,
74−the player uses a standard modal presentation. Programmatic entry uses the same
75−guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video;
76−if that selector is unavailable, the player also falls back to a standard modal.
77−
78−`FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset,
79−or a retained composer file. On iOS, Quick Look owns image and document layout, controls,
80−zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail
81−and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or
82−another image scroll view over that presentation: Quick Look coordinates its image gestures
83−with the return to the thumbnail. Missing sources use the standard transition, and Reduce
84−Motion disables animation. A pending programmatic Close waits until the current presentation
85−or cancelled dismissal has settled before starting another transition.
86−
87−The shared native presenter copies original bytes into its own temporary directory and
88−removes that copy after dismissal. Network downloads write to disk, and sharing never edits
89−the source attachment. Draft images use their stored upload data rather than a potentially
90−expired picker URI. No React Navigation route or custom transition animator is needed.
91−
92−The same viewer handles message images, markdown images, PDF attachments and links,
93−composer thumbnails, and workspace image previews. The workspace PDF web preview has an
94−Open PDF action for the native viewer. Android retains its image viewer and uses the
95−system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission.
96−
97−Every full-screen video preview on iOS is AVKit, playing directly from the signed asset
98−URL: sent attachments, composer drafts, and file links that point at a video. Inline
99−markdown embeds and the workspace file screen play in place with the Expo Video player.
100−AVKit handles buffering; the client does not download the entire file or show a separate
101−opening overlay before presentation.
102−The URL is captured once per preview so credential refresh does not restart playback.
103−Media actions (copy path, open in file viewer, save or share) belong to the surface that
104−opened the video, a long-press on the thumbnail, so the player carries no
105−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.
106−
107−The native presentation promise completes after dismissal. Local draft previews
108−hold their file lease until that promise settles. The iOS preview component requests
109−native dismissal when its source screen unmounts. Playback pauses in the background.
110−AVPlayer activates audio as playback starts. The presenter pauses and releases
111−its own player on close, then restores the previous audio-session configuration
112−if no other component changed it during playback. It does not deactivate the
113−shared session, which may still serve another player or recorder. Android plays every
114−video, attachments included, in its React Native modal with the Expo Video player.
115−Composer drafts play and share from their local file; other videos stream from the
116−signed URL and download only for save or share.
117−
118−`shareFileFromSource` uses the same source registration to anchor UIKit's activity
119−controller. Its promise completes when the native share flow finishes, keeping
120−the existing attachment lease and foreground handoff active for that duration.
121−Android uses Expo Sharing. On iOS, received and draft video attachments expose
122−Save or share through `VideoAttachmentMenu`. The attachment supplies the source
123−identifier, and the native share presentation inherits its appearance. AVKit's
124−iOS playback controls do not expose a public custom-share-action API.
125−
126−Video attachment thumbnails use Expo Video's native frame extraction and Expo Image.
127−Received attachments use their signed asset URL; drafts retain and resolve their local file
128−until extraction ends. Extraction is serial; temporary players never play or change audio settings. Leaving the screen cancels
129−pending work; a 15-second limit prevents an unreachable source from holding up the queue.
130−The client keeps at most 32 native images, each bounded to 480 pixels per side, keyed by
131−environment and attachment identity rather than expiring URLs. Images still displayed keep
132−their own references when evicted from that cache.
133−
134−The asset HTTP route supports single byte ranges for videos so iOS can read metadata and
135−frames without first downloading the whole file. Normal downloads keep their full response;
136−unsupported ranges and conditional `If-Range` requests also fall back to the full file.
137−An older environment without range support may still show the play-card fallback. Thumbnail
138−failure never disables playback or sharing.
33+iOS delegates full-screen video to [AVKit](../../apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift)
34+and images and documents to [Quick Look](../../apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift).
35+Each framework owns its controls and transition. A separate UIKit zoom transition
36+prevents AVKit's native Close action from exiting full screen and interferes with
37+Quick Look's interactive return to its thumbnail. The AVKit entry selector is
38+guarded, with standard modal presentation as the fallback.
39+
40+[Thumbnail registration](../../apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift)
41+holds weak view references for transition and share-sheet anchors. It does not own
42+the preview; a source row can disappear while a native presentation is open.
43+Identifiers must distinguish attachments that are visible at the same time.
44+Native promises complete after dismissal so callers keep local file leases until
45+the preview or share flow has finished.
46+
47+Treat a dismissal request as pending until UIKit finishes its current transition.
48+Starting another dismissal while presentation or an interactive dismissal is
49+settling can strand the controller. Video cleanup restores the previous audio
50+configuration only if it still matches the preview's configuration. It must not
51+deactivate the shared audio session, which may belong to another player or recorder.
52+
53+Video playback holds the first signed asset URL for the lifetime of the preview.
54+Following credential refreshes reactively would restart playback. Quick Look copies
55+original bytes into its own temporary directory so preview and sharing cannot
56+mutate a draft or workspace file.
docs/internals/model-manifest.md+23−40
@@ -1,42 +1,25 @@
11 # Model manifest
22
3−`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from
4−`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic
5−catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the
6−last successful remote manifest. The bundle is used when no valid remote cache exists, or when
7−the bundle's `updatedAt` is later than the cached manifest's, so a release that edits the manifest
8−takes effect before the next successful fetch. Bump `updatedAt` whenever you edit the file.
9−
10−The top-level provider catalog is generic: models contain presentation metadata, aliases, status,
11−an optional badge, and a reusable capability profile. The profile and model `adapter` fields are
12−opaque until the owning provider validates them with its own allowlisted schema.
13−
14−Claude Code uses the manifest as its complete built-in model catalog. To add a Claude model that
15−uses an existing profile, add one object to `providers.claudeAgent.models`. Do not add a test or
16−change application code. Add or change a profile in the same JSON file only when the model exposes
17−a capability combination that does not already exist.
18−
19−`currentModels.claudeAgent` is retained as a frozen compatibility field for releases that predate
20−catalog discovery. New Claude models do not need to be added there. Codex still discovers models
21−from its app server and uses `currentModels.codex` only as a legacy-classification overlay.
22−
23−Claude model entries support:
24−
25−- `aliases`, `status`, `badge`, and `profile` for client presentation and selection.
26−- `adapter.claudeCode.minVersion` and `maxVersionExclusive` for installed-runtime compatibility.
27−- Profile-level effort mappings, model suffixes, and context-window metadata for dispatch.
28−
29−## Test policy
30−
31−Changing model data does not require tests. Do not add or update tests for a model slug, display
32−name, alias, legacy status, version boundary, badge, or profile assignment. The bundled manifest is
33−configuration and is validated by its schema when imported.
34−
35−Add tests only when implementation behavior changes:
36−
37−- Fetching, caching, fallback, or schema-version handling changes in the manifest service.
38−- Provider-neutral profile resolution gains new semantics.
39−- A provider adapter gains a new compatibility or dispatch mapping type.
40−
41−Resolver tests must use synthetic providers and model names so normal JSON edits never create test
42−churn.
3+The [bundled manifest](../../apps/server/src/provider/model-manifest.json) allows
4+offline startup; fetching it from `main` lets model metadata change between
5+releases. Failed fetches or invalid data preserve the last usable manifest.
6+Remote data must pass both catalog-reference validation and the owning provider's
7+adapter validation before replacing the cache.
8+
9+A newer bundle outranks the cached remote manifest by `updatedAt`, so a release can
10+correct model data before the next successful fetch. Bump `updatedAt` whenever the
11+file changes. Fetch time cannot establish which copy contains the newer edit.
12+
13+Generic catalog data describes presentation and capabilities. Each provider owns
14+its adapter schema and dispatch mappings. Claude uses the manifest for its entire
15+built-in catalog. Adding a model with an existing capability profile is a JSON
16+edit; a new profile is needed only for a new capability combination. Codex still
17+gets its model list from its app server.
18+
19+`currentModels.claudeAgent` is frozen for releases that predate catalog discovery.
20+Do not extend it when adding Claude models. Codex uses `currentModels.codex` as a
21+legacy-classification overlay for discovered models.
22+
23+Model data is schema-validated configuration. Tests should cover resolver, cache,
24+and adapter semantics with synthetic model names, so adding a model never requires
25+tests that repeat the configuration.
docs/internals/overview.md+65−177
@@ -1,179 +1,67 @@
11 # Architecture
22
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−T3 Code is a server runtime that owns agent sessions, workspaces, and version control, plus clients
6−(web, desktop, mobile) that talk to it over one authenticated Effect RPC WebSocket. The server is the
7−execution boundary: every provider process, terminal, git operation, and filesystem read happens
8−there, never in the client.
9−
10−```
11−┌────────────────────────────────────────────────┐
12−│ Clients: apps/web, apps/desktop, apps/mobile │
13−│ shared runtime: packages/client-runtime │
14−│ connection supervisor, RPC session, Atom state│
15−└──────────────────┬─────────────────────────────┘
16− │ Effect RPC over WebSocket (/ws)
17− │ contract: packages/contracts
18−┌──────────────────▼─────────────────────────────┐
19−│ apps/server │
20−│ orchestration engine (event-sourced) │
21−│ provider driver registry (6 built-in drivers) │
22−│ checkpointing, VCS, terminals, filesystem │
23−└──────────────────┬─────────────────────────────┘
24− │ per-driver transport
25−┌──────────────────▼─────────────────────────────┐
26−│ Agent CLIs: Codex, Claude, Cursor, Grok, │
27−│ OpenCode, Antigravity │
28−└────────────────────────────────────────────────┘
29−```
30−
31−## The RPC boundary
32−
33−The client/server contract is an Effect RPC group, not a hand-rolled push protocol. [`rpc.ts`][rpc]
34−declares `WS_METHODS` and assembles `WsRpcGroup`; each member is either unary or a server stream
35−(`stream: true`). Streaming members such as `orchestration.subscribeShell`,
36−`orchestration.subscribeThread`, `subscribeServerConfig`, and `terminal.attach` replace what used to
37−be a broadcast push bus: a client subscribes to what it needs and the server pushes only on that
38−subscription.
39−
40−[`ws.ts`][ws] serves the group. `websocketRpcRouteLayer` mounts `GET /ws`, authenticates the upgrade
41−through `EnvironmentAuth.authenticateWebSocketUpgrade`, then hands the socket to
42−`RpcServer.toHttpEffectWebsocket`. Authorization is per method: `RPC_REQUIRED_SCOPE` maps each method
43−to a scope, and `authorizeEffect`/`authorizeStream` enforce it. Holding a valid socket is not
44−authorization to call everything on it. See [environment-auth.md](./environment-auth.md).
45−
46−On the client, [`session.ts`][session] opens the socket and builds the typed client.
47−`RpcSessionFactory` is the service; a session exposes `client`, `initialConfig`, `ready`, `probe`,
48−and `closed`. It performs one attempt and does not retry. Retry, backoff, and offline policy belong
49−to the connection supervisor.
50−
51−## Shared client runtime
52−
53−`packages/client-runtime` holds every non-visual client concern: connection lifecycle,
54−authentication, RPC, cached environment data, and domain state as Atom factories. Web and mobile
55−compose it the same way (`apps/web/src/connection/runtime.ts` and
56−`apps/mobile/src/connection/runtime.ts` mirror each other, differing only in platform-specific
57−background-activity layers) and differ beyond that only in the platform layer they supply and the
58−UI they build on top. React components never construct transports, retry loops,
59−or RPC clients. See [connection-runtime.md](./connection-runtime.md).
60−
61−## Orchestration is event-sourced
62−
63−The server does not mutate app state directly. Clients dispatch typed commands; the engine turns them
64−into persisted events; projections derive the read model.
65−
66−[`OrchestrationEngine.ts`][engine] serializes this. `dispatch` offers a `CommandEnvelope` onto
67−`commandQueue` and awaits its result; a single worker fiber takes envelopes one at a time, so command
68−processing is totally ordered. For each envelope `processEnvelope`:
69−
70−1. checks the durable command receipt, making retries idempotent;
71−2. runs `decideOrchestrationCommand` ([`decider.ts`][decider]) to produce events from command plus
72− current state, pure and side-effect free;
73−3. inside one SQL transaction, appends events to the event store, applies them to the in-memory read
74− model via [`projector.ts`][projector], projects them into persisted tables, and writes the
75− accepted receipt;
76−4. after commit, swaps in the new read model, cleans up attachments, and publishes committed events
77− to subscribers. Attachment cleanup failures are logged and do not reject committed commands.
78−
79−Because persistence and projection share a transaction, the read model cannot durably disagree with
80−the event log. On dispatch failure the engine rereads persisted events past the starting sequence and
81−reconciles.
82−
83−Command and event names live in [`orchestration.ts`][contracts]. Some commands are client
84−dispatchable (`thread.create`, `thread.turn.start`, `thread.approval.respond`); others are internal
85−and produced only by server-side reactors (`thread.message.assistant.delta`,
86−`thread.turn.diff.complete`).
87−
88−A turn is complete when its session leaves `running` status, projected by
89−`settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later
90−does not define turn end.
91−
92−Thread settlement is server-owned. Each server's own settings control PR and inactivity
93−settlement. Those keys are user preferences, so clients write them to every shared-settings sync
94−target (`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and
95−warn when another target drifts. A target must have an active connection and advertise the
96−`threadAutoSettlement` capability, which signals that the server can hold every shared key.
97−[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and
98−once per minute, including when no client is connected. It dispatches the guarded internal
99−`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic
100−settlement excludes live background work and requires a comparable PR timestamp for immediate PR
101−settlement. The command carries the latest activity timestamp and rejects any later event for its
102−thread after the reactor's snapshot. The sweep looks a branch up from the thread's worktree when it
103−still exists, so it shares the per-cwd PR cache the sidebar polls instead of spending a second host
104−request. Clients render the persisted settlement state and do not derive settlement from PR or
105−inactivity state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle
106−provider session.
107−
108−At turn completion, `CheckpointReactor` refreshes PR discovery when the checkout matches the
109−thread's non-default branch. `VcsStatusBroadcaster` requires loaded remote status and permission
110−from background policy. `GitManager` retries only a successful "no PR" cache entry for the current
111−branch, preserving known PRs and failure backoff without fetching remotes. Remote status reads
112−that write the broadcaster cache share a lock per cwd, including the initial status load.
113−
114−## Drainable workers
115−
116−Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]:
117−[`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands,
118−[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events,
119−[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints, and
120−[`ThreadSettlementReactor`][settlement] evaluates server-owned automatic settlement rules.
121−
122−`DrainableWorker` pairs a transactional queue with a transactional count of outstanding items.
123−`enqueue` atomically offers and increments; processing always decrements. `drain` retries until the
124−count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping.
125−Each of these four services exposes `drain` for exactly this.
126−
127−Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in
128−[`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not
129−build production behavior on receipts.
130−
131−## Provider drivers
132−
133−Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:
134−Codex, Claude, Cursor, Grok, OpenCode, and Antigravity. A driver declares its kind and config schema and creates a
135−scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves
136−an instance to its adapter, so `ProviderService` routes session and turn operations without knowing
137−which agent is behind them. See [providers.md](./providers.md).
138−
139−## Checkpointing
140−
141−Each turn is bracketed by workspace checkpoints so diffs and reverts are exact. `CheckpointStore`
142−captures state as hidden Git refs through the VCS driver's checkpoint operations;
143−`CheckpointDiffQuery` answers turn and full-thread diff requests; `CheckpointReactor` coordinates
144−baseline capture, completed-turn capture, diff projection, and reverting both the workspace and the
145−provider conversation. The storage contract is `VcsCheckpointOps` in
146−[`VcsDriver.ts`](../../apps/server/src/vcs/VcsDriver.ts), implemented for Git in the same directory.
147−
148−## Startup
149−
150−[`serverRuntimeStartup.ts`][startup] runs a fixed lifecycle: start keybindings, settings, and
151−reactors; publish welcome; signal command readiness (logged as `Accepting commands`); wait for the
152−HTTP listener via `markHttpListening`; publish ready; fork the heartbeat; then either print headless
153−output or open the browser. Command readiness precedes the listener, so a socket that opens can
154−already dispatch.
155−
156−## Related
157−
158−- [Workspace layout](./workspace-layout.md), [Glossary](./glossary.md)
159−- [Mobile navigation headers](./mobile-navigation.md)
160−- [Remote environments](./remote.md), [Server updates](./server-updates.md)
161−- [Resource telemetry](./resource-telemetry.md)
162−- [Product analytics](./product-analytics.md)
163−- [Scripts](./scripts.md), [CI gates](./ci.md)
164−
165−[rpc]: ../../packages/contracts/src/rpc.ts
166−[contracts]: ../../packages/contracts/src/orchestration.ts
167−[ws]: ../../apps/server/src/ws.ts
168−[session]: ../../packages/client-runtime/src/rpc/session.ts
169−[startup]: ../../apps/server/src/serverRuntimeStartup.ts
170−[engine]: ../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts
171−[decider]: ../../apps/server/src/orchestration/decider.ts
172−[projector]: ../../apps/server/src/orchestration/projector.ts
173−[worker]: ../../packages/shared/src/DrainableWorker.ts
174−[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
175−[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
176−[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts
177−[settlement]: ../../apps/server/src/orchestration/ThreadSettlementReactor.ts
178−[receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts
179−[drivers]: ../../apps/server/src/provider/builtInDrivers.ts
3+T3 Code keeps execution in the environment that owns the workspace. Web, desktop, and mobile
4+clients control it over authenticated RPC. A remote client must never substitute its own filesystem,
5+provider credentials, or machine state for the environment's. The desktop app bundles a server,
6+but its renderer follows the same boundary.
7+
8+## Ownership boundaries
9+
10+Provider processes, terminals, Git, and project files belong to the server. Shared connection and
11+domain state belongs in `packages/client-runtime`; clients supply platform services and UI.
12+Keeping that logic shared prevents reconnect and multi-environment behavior from diverging between
13+web and mobile. See [connection runtime](./connection-runtime.md) and
14+[remote environments](./remote.md).
15+
16+The [RPC contract](../../packages/contracts/src/rpc.ts) is the boundary between independently
17+versioned clients and servers. Subscriptions send the state a client needs, so a client viewing one
18+thread does not pay for every thread's history. Authentication of a socket does not authorize every
19+method on it. See [environment auth](./environment-auth.md).
20+
21+Provider-specific behavior belongs behind an adapter. Orchestration works with normalized commands
22+and events, so adding a provider should not require branches throughout the domain or clients.
23+See [provider constraints](./providers.md).
24+
25+## Durable intent and side effects
26+
27+The event log is the source of truth for orchestration state. The
28+[engine](../../apps/server/src/orchestration/Layers/OrchestrationEngine.ts) serializes commands;
29+the [decider](../../apps/server/src/orchestration/decider.ts) produces events without performing
30+provider or filesystem work. Events, persisted projections, and the accepted command receipt commit
31+in one database transaction. The in-memory state changes and subscribers receive events after that
32+commit. This keeps command retries idempotent and prevents a persisted projection from getting
33+ahead of the event log.
34+
35+Reactors perform side effects after intent has been recorded, then feed results back through
36+commands. A command acknowledgement therefore means the intent committed, not that the provider,
37+checkpoint, or other follow-up work finished. Keep external I/O out of the decider and the database
38+transaction.
39+
40+Persisted events must remain decodable on replay. Changing a schema affects old environments at
41+startup as well as live RPC traffic. Compatibility work must account for stored history, not just
42+what the newest client sends.
43+
44+## Turn completion and checkpoints
45+
46+A turn ending and its follow-up work settling are separate milestones. The
47+[projector](../../apps/server/src/orchestration/projector.ts) settles the turn from its session
48+status. A late checkpoint or diff must not extend the recorded turn duration or keep the client
49+showing provider work as active.
50+
51+[Checkpoints](../../apps/server/src/checkpointing/CheckpointStore.ts) use hidden Git refs to
52+capture workspace state without adding commits to the user's branch. A revert must coordinate
53+workspace state with the provider conversation. A provider that cannot roll back its conversation
54+must reject that operation before changing the filesystem.
55+
56+## Waiting for asynchronous work
57+
58+Tests use [drainable workers](../../packages/shared/src/DrainableWorker.ts) to wait until both the
59+queue and its current item have finished. An empty queue alone does not prove the worker is idle.
60+
61+Runtime receipts mark specific test milestones. Their
62+[production layer](../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts) is a no-op;
63+production behavior must use persisted state and events. These test signals are separate from the
64+durable command receipts that make dispatch idempotent.
65+
66+See the [glossary](./glossary.md) for shared terms and the
67+[development runbook](../operations/development.md) for setup and checks.
docs/internals/product-analytics.md+47−175
@@ -1,177 +1,49 @@
11 # Product analytics
22
3−T3 Code sends anonymous product events from the server to PostHog. The server
4−uses the first available hashed Codex account ID, hashed Claude user ID, or
5−installation-scoped anonymous ID as the distinct ID. It also keeps the
6−telemetry opt-out, event buffer, and batch delivery. Clients do not load the
7−PostHog browser SDK.
8−
9−## Client events
10−
11−These events use the metadata from the WebSocket connection that caused them.
12−The metadata is not a person property or server-global current-client value.
13−Two clients connected to one server can report different values at the same
14−time.
15−
16−| Event | Description |
17−| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
18−| `client.connected` | The server accepted an authenticated WebSocket connection. Reconnects count again. Use this event for connection diagnostics, not active-use counts. |
19−| `client.thread.started` | The server accepted a command that created a thread. |
20−| `client.turn.requested` | The server accepted a turn request. This is the standard active-use event. |
21−
22−`provider.turn.sent` stays a provider execution event. It does not receive
23−client metadata because a provider turn can continue after the requesting
24−client disconnects.
25−
26−## Provider turn events
27−
28−`provider.turn.completed` records one anonymous event when a provider emits a
29−canonical completed or aborted turn. The event reports normalized main-agent
30−token counts when the provider supplies enough data.
31−
32−| Property | Values and meaning |
33−| --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
34−| `provider` | Provider driver, such as `codex`, `claudeAgent`, or `opencode`. Use this as the harness breakdown. |
35−| `model` | Selected or provider-reported model when known. This is metadata, not proof that one model produced every token. |
36−| `effort` | Selected or provider-reported reasoning effort when known. |
37−| `interactionMode` | Turn interaction mode, `default` or `plan`, when known. |
38−| `runtimeMode` | Session permission mode when known. |
39−| `mixedModels` | `true` when T3 Code observed `model.rerouted` during the turn. This does not split token counts by model. |
40−| `durationMs` | Time from the observed turn start or accepted send request to the terminal event. Omitted when start metadata is not available. |
41−| `terminalStatus` | `completed`, `failed`, `interrupted`, or `cancelled`. |
42−| `usageStatus` | `complete`, `partial`, or `unavailable`. |
43−| `usageScope` | `main_agent`. Child-agent token use is not included. |
44−| `hasSubagents` | Whether the adapter observed child agents during the turn. Omitted when the adapter does not provide a normalized usage report. |
45−| `inputTokens` | Total main-agent input tokens. This includes uncached input, cache reads, and cache creation or writes. |
46−| `cachedInputTokens` | Cache-read tokens. This is a subset of `inputTokens`. |
47−| `cacheCreationTokens` | Cache creation or write tokens. This is a subset of `inputTokens`. |
48−| `outputTokens` | Total main-agent output tokens, including reasoning tokens. |
49−| `reasoningTokens` | Reasoning tokens when the provider reports them separately. This is a subset of `outputTokens`. |
50−
51−`complete` means the provider supplied full input and output totals for the
52−turn. `partial` means each included count is valid, but T3 Code did not observe
53−the full turn. This can happen after an interruption, provider failure, or
54−reconnect. `unavailable` means the provider did not supply trustworthy counts.
55−Numeric properties are omitted when their values are unknown. They are not set
56−to zero.
57−
58−Codex uses deltas from its cumulative token totals, and falls back to the
59−newest response's `last` usage when no prior total exists or the total was
60−reset. Claude uses the final result's per-turn `usage` value and does not use
61−cumulative `modelUsage`.
62−OpenCode sums unique main-session step totals. Cursor, Grok, and Antigravity
63−usage stays `unavailable` until their ACP token fields and scope are verified.
64−
65−The completed event count does not have to match `provider.turn.sent`. For
66−example, Claude can emit synthetic turns without a matching send request.
67−Duplicate terminal events for the same provider instance, thread, and turn
68−produce one analytics event.
69−
70−Collection is best effort and starts with the first server release that
71−contains this event. T3 Code does not scan provider history or backfill older
72−turns.
73−
74−## Recommended properties
75−
76−Client properties appear on the three client events when the connected client
77−reports them. Older clients can omit every client property.
78−
79−| Property | Values and meaning |
80−| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81−| `surface` | Product client: `web`, `desktop`, or `mobile`. |
82−| `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. |
83−| `clientOs` | `macOS`, `Windows`, `Linux`, `iOS`, `Android`, `ChromeOS`, `other`, or `unknown`. |
84−| `clientDeviceType` | `desktop`, `phone`, `tablet`, or `unknown`. This is separate from `surface`. |
85−| `clientBrowser` | Normalized browser family. Web only. Browser detection is best effort. |
86−| `clientAppVersion` | Version of the connected client. |
87−| `clientOsMajorVersion` | Client OS major version when the native client reports it. Initially mobile only. |
88−| `clientDeviceModel` | Hardware model when the native client reports it. Initially mobile only. This is not a user-assigned device name. |
89−| `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. |
90−
91−Server properties appear on all events, including server boot and background
92−events.
93−
94−| Property | Values and meaning |
95−| ------------------ | -------------------------------------------------------------- |
96−| `serverOs` | Server process OS, normalized to the same names as `clientOs`. |
97−| `serverArch` | Server process architecture. |
98−| `serverWslDistro` | WSL distribution from `WSL_DISTRO_NAME`, when present. |
99−| `serverAppVersion` | T3 server version. |
100−| `serverMode` | Server runtime mode: `desktop` or `web`. |
101−
102−## Legacy properties
103−
104−Existing property meanings do not change:
105−
106−- `clientType` describes how the server runs. It is `desktop-app` for a desktop
107− server and `cli-web-client` for a CLI web server. It does not describe the
108− connected client. Use `surface` and `webDeployment` for new reports.
109−- `platform`, `arch`, `wsl`, and `t3CodeVersion` describe the server. Use the
110− new `server*` names for new reports.
111−- `appVersion` describes the connected client. Use `clientAppVersion` for new
112− reports.
113−- Mobile connection events keep `os`, `osMajorVersion`, and `deviceModel`.
114− Use the new `client*` names for new reports.
115−
116−## PostHog dashboard
117−
118−Create one saved dashboard named `Client and platform usage`. Set
119−`client.turn.requested` as the event for active-use reports. A user can appear
120−in several client groups during one period, so do not add breakdown values to
121−calculate a total.
122−
123−Save these insights:
124−
125−| Insight | Configuration |
126−| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127−| Active users, daily | Trends, `client.turn.requested`, unique users, daily interval. |
128−| Active users, weekly | Trends, `client.turn.requested`, unique users, weekly interval. |
129−| Active users, monthly | Trends, `client.turn.requested`, unique users, monthly interval. |
130−| 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. |
131−| Client OS, active users | Trends, `client.turn.requested`, unique users, breakdown by `clientOs`. |
132−| Client OS, turns | Trends, `client.turn.requested`, total events, breakdown by `clientOs`. |
133−| Client versus server OS | Table, `client.turn.requested`, breakdown by `clientOs` and `serverOs`. |
134−| Connection method, active users | Trends, `client.turn.requested`, unique users, breakdown by `connectionMethod`. |
135−| Connection method, turns | Trends, `client.turn.requested`, total events, breakdown by `connectionMethod`. |
136−| Mobile devices | Table, `client.turn.requested`, filter `surface = mobile`, breakdown by `clientOs`, `clientOsMajorVersion`, and `clientDeviceType`. |
137−| Client version adoption | Trends, `client.turn.requested`, unique users, breakdown by `clientAppVersion`. |
138−| Server version adoption | Trends, `client.turn.requested`, unique users, breakdown by `serverAppVersion`. |
139−| 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`. |
140−
141−In PostHog Data management, use the event and property descriptions from this
142−document. Mark the recommended properties as verified. Keep `clientType`
143−visible with its legacy description so old reports remain understandable.
144−
145−Create a second saved dashboard named `Provider token efficiency`. Start each
146−token insight with these filters:
147−
148−- `usageStatus = complete`
149−- `hasSubagents = false`
150−- `mixedModels = false`
151−
152−Break down input tokens, output tokens, and their ratio by `provider`. Compare
153−the same `model`, `effort`, `interactionMode`, and `terminalStatus`. Without
154−these filters, model choice, reasoning level, plan mode, failures, and child
155−agents can look like harness differences. Keep separate views for median turn
156−usage and aggregate `sum(outputTokens) / sum(inputTokens)`. Do not average each
157−turn's ratio because small-input turns can dominate that result.
158−
159−## Collection and release boundary
160−
161−Client values are best effort. Invalid values are ignored and never reject a
162−connection. Browser clients use user-agent data for broad OS, browser, phone,
163−and tablet groups. They do not infer CPU architecture or an exact OS version
164−from `navigator.platform`.
165−
166−This change does not collect URLs, authentication tokens, prompts, IP
167−addresses, or user-assigned device names. The token event does not include
168−thread IDs, turn IDs, provider instance IDs, event IDs, request IDs, raw
169−provider payloads, or child-agent output. It does not scan provider history on
170−disk or start external work. It measures authenticated product use. It does
171−not measure a person who visits the hosted app without connecting to a server.
172−
173−The new fields start with the first client and server release that contains
174−this metadata path. Historical events cannot reliably identify the client OS,
175−hosted web use, device type, or connection method when the old client did not
176−send those fields. Reports must treat missing values as pre-release or older
177−client data instead of backfilling them from server fields.
3+The server owns PostHog delivery, opt-out, and identity for every connected client.
4+[Identity selection](../../apps/server/src/telemetry/Identify.ts) hashes an available
5+provider account ID, falling back to an installation-scoped ID. This identity can
6+span several clients; it does not identify a browser session. Clients do not load
7+the PostHog browser SDK. Client-use events require an authenticated connection, so
8+visiting the hosted app without connecting does not count as product use.
9+
10+## Attribution boundaries
11+
12+Client dimensions belong to the event's WebSocket connection. A server-global
13+"current client" would misattribute simultaneous web, desktop, and mobile use.
14+Provider execution has its own events because a turn can outlive the requesting
15+connection.
16+
17+Keep client and server dimensions separate. A desktop host can serve a phone or a
18+remote browser, and a direct connection can cross a network. Older clients omit
19+metadata. Missing client values must stay unknown rather than being backfilled
20+from server properties. The legacy `clientType` property describes how the server
21+runs; use `surface` for the connected client.
22+
23+## Interpreting events
24+
25+Use `client.turn.requested` for active-use reports. `client.connected` counts
26+reconnects, so network behavior can inflate it. One identity can appear in several
27+client groups during a period; adding those groups double-counts users.
28+
29+Provider send and completion counts need not match. Providers can emit synthetic
30+turns without a send request. Collection is best effort, with no scan or backfill
31+of provider history.
32+
33+Token totals cover the main agent. Child agents and model rerouting prevent a turn
34+from representing one provider/model combination's full cost. For provider
35+comparisons, require complete usage, no observed subagents, and no mixed models;
36+compare matching model, effort, interaction mode, and terminal status. Aggregate
37+output/input ratios should divide the summed totals. Averaging per-turn ratios
38+lets small-input turns dominate.
39+
40+Unknown counts stay absent. Partial usage contains valid observed counts but
41+cannot establish a whole-turn total. Keep these distinctions when changing token
42+normalization or building reports.
43+
44+## Collection boundary
45+
46+Keep analytics payloads to product metadata and normalized measurements. Do not
47+send prompts, authentication material, raw provider payloads, user-assigned device
48+names, or conversation identifiers. Client metadata is best effort; invalid values
49+must not reject a connection. PostHog person profiles remain disabled.
docs/internals/providers.md+88−395
@@ -1,395 +1,88 @@
1−# Provider architecture
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−A provider is the agent runtime that does the actual work. T3 Code supports several, and the
6−orchestration layer does not know which one is behind a thread.
7−
8−## Built-in drivers
9−
10−[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries:
11−
12−| Driver kind | Driver source |
13−| ------------- | --------------------------------------------- |
14−| `codex` | [`Drivers/CodexDriver.ts`][codex] |
15−| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
16−| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
17−| `grok` | [`Drivers/GrokDriver.ts`][grok] |
18−| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
19−| `antigravity` | [`Drivers/AntigravityDriver.ts`][antigravity] |
20−
21−Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an
22−adapter in a child scope. Adapter implementations live beside them in
23−`apps/server/src/provider/Layers/` (`CodexAdapter.ts`, `ClaudeAdapter.ts`, and so on) and conform to
24−[`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's
25−transport, config, and event shapes are mapped.
26−
27−## Runtime context
28−
29−Every adapter uses `apps/server/src/provider/RuntimeInstructions.ts` to identify T3 Code and
30−the harness, and describe Markdown image/video embeds. Codex includes it in developer
31−instructions; Claude appends it to its system preset; OpenCode sends it in each prompt's
32−`system` field. Cursor, Grok, and Antigravity append a separate text block to ACP prompts,
33−which have no system-message field. This does not change the stored user message.
34−
35−Per-turn context includes the current model when known. Codex includes reasoning effort;
36−Grok includes it when explicitly selected for the turn. Claude's session-level context omits model and effort because they can
37−change during a session. OpenCode variants are not assumed to be reasoning-effort levels.
38−
39−## Codex async questions
40−
41−Codex 0.153 exposes `request_user_input_async` through `item/started` and `item/completed`
42−notifications. The item has `type: "agentMessage"`, `delivery: "async"`, and a `questions` array.
43−Each question has a `title` and an optional `options` array of strings. The tool returns `{"accepted":true}`
44−without waiting. This is separate from the `item/tool/requestUserInput` server request.
45−See the [Codex tool handler](https://github.com/openai/codex/blob/d979df154cf60e13eafb5453e75b6d84f21c67bf/codex-rs/core/src/tools/handlers/request_user_input_async.rs).
46−
47−The Codex adapter maps completed question items to `user-input.requested` with
48−`responseMode: "message"` and stable request and event IDs. Questions use the existing web,
49−desktop, and mobile panels. They stay pending while the turn runs and after it finishes.
50−
51−The engine reads the request's latest stored activity before deciding a reply. This works after
52−startup, when the command snapshot has no activities, and after a resolution leaves the recent
53−activity window. The query returns one activity, not the full thread history.
54−
55−For these requests, the decider saves the resolution and a user message in one transaction.
56−The standard turn path delivers the message, including session resume and active-turn input.
57−It does not send a JSON-RPC response to Codex. Other providers and blocking Codex questions
58−keep their existing response paths.
59−
60−## Registry and routing
61−
62−Two registries separate configuration from live processes:
63−
64−- [`ProviderInstanceRegistry`][instances] keys configured instances by `ProviderInstanceId`. Creating
65− one looks up the driver by `driverKind`, decodes `entry.config` with that driver's schema, opens a
66− child scope, and calls `driver.create`.
67−- [`ProviderAdapterRegistry`][registry] resolves an instance ID to its live adapter via
68− `getByInstance`.
69−
70−[`ProviderService`][service] sits on top. It combines the adapter registry with the provider session
71−directory to route session and turn operations for a thread, so callers name a thread, not an agent.
72−
73−`ProviderService.sendTurn` expands [assistant citations](./assistant-citations.md) into quoted
74−reference data before dispatching to any adapter. Bound user comments remain distinct from the quoted
75−assistant text. Persisted messages keep their serialized links.
76−
77−Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No
78−orchestration, contract, or client change is required for the common case.
79−
80−### Grok health check
81−
82−`checkGrokProviderStatus` never opens an ACP session. It runs `grok --version`, then `grok models`
83−for login state and model slugs, then a single ACP `initialize` and reads models from
84−`_meta.modelState`. `authenticate` and `session/new` are skipped on purpose: `authenticate` can open
85−a browser login and `session/new` boots every configured MCP server, both of which made background
86−probes hang or surprise the user. A failed `initialize` degrades to `warning` with the CLI's model
87−list instead of persisting `error` over a working install. The built-in `grok-build` slug is the
88−CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the
89−session's current model" and never sends it in `session/set_model`.
90−
91−## Antigravity ownership and protocol
92−
93−[`AntigravityDriver`][antigravity] uses Google's official ACP executable. The instance config
94−selects the ACP auth method: `oauth-personal` (default), `oauth-business`, `gemini-api-key`, or
95−`agent-platform`. The two OAuth methods share the loopback sign-in flow below. The API key
96−methods pass the configured key to the agent as `GEMINI_API_KEY` or `GOOGLE_API_KEY` and never
97−open a browser. A GCP project and location are written to the profile's `settings.json` on each
98−launch. The driver never reuses CLI credentials or ambient `GOOGLE_*` variables and never falls
99−back to another method. Antigravity is disabled by default and supports multiple provider
100−instances. The open driver and instance identifiers require no database migration.
101−
102−### Runtime installation
103−
104−[`AntigravityInstallation`][antigravity-installation] belongs to the environment, outside
105−WebSocket and provider-instance scopes. Instances share an explicit download operation and the
106−completed runtime. Client disconnects and instance rebuilds do not cancel installation.
107−
108−The fixed [release table][antigravity-release] contains official Google URLs, SHA-256 hashes,
109−archive sizes, and the exact executable pair for each published host. Downloads stream to disk.
110−Lazy `yauzl` entry streams extract only that pair, with member names, types, duplicates, and
111−sizes checked. Validation runs ACP `initialize` in a temporary profile without authentication
112−or a session. Progress updates are bounded, not sent for every network chunk.
113−
114−Complete releases live in immutable version directories under the T3 home
115−`tools/antigravity-acp/<platform>-<arch>/versions`. An atomic `active.json` change selects the
116−release for new processes. Each process holds a version lease until it exits. Updates do not
117−replace running executables. Removal refuses active leases or explicit binary paths that still
118−reference the managed files. Failure or cancellation removes owned partial files, not the
119−previous release or account data.
120−
121−Resolution order is explicit `binaryPath`, active managed release, then the instance's `PATH`.
122−An invalid explicit path fails without fallback. Manual installations are never changed by the
123−installer. Every launch pins `ANTIGRAVITY_HARNESS_PATH` to the selected executable's sibling.
124−
125−### Google profiles and sign-in
126−
127−Each instance owns a stable profile at
128−`<stateDir>/providers/antigravity/<sha256(instanceId)>`.
129−[`antigravityAuthSupport.ts`][antigravity-auth-support] sets `GEMINI_HOME` to this directory and
130−`AGY_ACP_FORCE_FILE_STORAGE=1` after merging instance environment variables. File storage avoids
131−the official macOS keychain entry being shared across instances. Profile directories use mode
132−`0700` on POSIX. This is file storage, not an encrypted keychain. Windows uses the host profile's
133−filesystem permissions.
134−
135−The launch environment removes API-key and cloud-billing variables, disables inherited
136−environment extension, sets `PYTHONUNBUFFERED=1`, and controls `BROWSER`. A tested Node or
137−Electron-as-Node helper prevents the official agent from opening a browser on the environment.
138−The same launch factory serves setup, health checks, chat, and text generation.
139−
140−The official agent prints a non-JSON OAuth line on stderr in version 1.1.1. Earlier versions
141−print it on stdout. T3 accepts the exact native prefix on either stream and its browser-helper
142−marker on stderr. Fragmented lines are joined and bounded. Other malformed protocol output
143−remains fatal. Authorization URLs are validated before use. Other stderr is discarded because
144−it can contain OAuth data. Normal work rejects an interactive login request with a
145−sign-in-required error instead of waiting for consent. A rejected stderr callback fails pending
146−ACP requests and closes the owned process.
147−
148−[`AntigravityAuth`][antigravity-auth] owns each sign-in process and deadline in the instance
149−scope. Only the initiating T3 auth session receives its URL and flow ID or can complete or
150−cancel it. Other clients receive busy state without those values. Subscriptions follow
151−controller replacement when settings rebuild an instance.
152−
153−For remote completion, the client sends the full return URL through the typed setup RPC.
154−The server validates the pending loopback origin, port, root path, and single matching state
155−before forwarding once to the owned listener. It does not probe the listener or follow
156−redirects. Google's process owns PKCE, token exchange, refresh, and storage. Callback HTTP
157−success is not authentication success. The controller waits for authenticated session setup
158−and catalog discovery. Cancellation closes the process instead of sending a synthetic denial.
159−
160−Auth RPCs `provider.auth.start`, `complete`, `cancel`, `logout`, and `subscribe` require
161−`orchestration:operate`. Install `start`, `cancel`, and `remove` use that scope too.
162−`provider.install.subscribe` and public provider snapshots require `orchestration:read`.
163−[`providerSetup.ts`][provider-setup] defines the operation IDs, states, and safe errors.
164−
165−Sign-out closes process admission for the instance, stops provider bindings through
166−[`ProviderAuthService`][provider-auth-service], then stops owned startup and helper processes.
167−A fresh official process calls `initialize` and native `logout` without authenticating.
168−Only then does the provider clear auth, models, commands, skills, and workspace metadata.
169−Thread history and native session files remain. Settings sign-out and a text-only `/logout`
170−use this same path. The command is handled before model prompting or title generation.
171−Disabling an instance closes its processes but keeps credentials. Account replacement is
172−explicit sign-out followed by sign-in.
173−
174−### Sessions, models, and client capabilities
175−
176−[`AntigravityAdapter`][antigravity-adapter] owns one ACP process per active thread. It uses
177−native `session/resume` without transcript replay and reapplies the persisted model and
178−permission mode after new or resumed setup. An unavailable explicit model fails instead of
179−accepting the native default. Steering cancels the previous prompt, waits for its result and
180−event drain, then sends the replacement. Native background commands use T3's existing
181−background-task state.
182−
183−The permission mapping is `approval-required` and `auto` to `default`, `auto-accept-edits` to
184−`auto_edit`, and `full-access` to `yolo`. Native requests still need replies in `yolo`.
185−`interaction_` requests are user questions, not approvals. T3 keeps opaque option IDs in
186−`UserInputQuestion.options[].value` and sets `allowCustomAnswer=false`. Both clients preserve
187−these values. Ordinary approval replies use only offered option IDs, including `allow_always`
188−only when present. Existing providers keep their prior behavior when the optional fields are
189−absent.
190−
191−`showInteractionModeToggle=false` keeps native `/plan` separate from T3 Plan mode.
192−`supportsConversationRollback=false` hides unsupported client actions and makes checkpoint
193−revert fail before filesystem changes. Checkpoint capture and diffs remain supported.
194−
195−Automatic status refreshes, reconnects, and workspace checks do not open catalog sessions.
196−Health probes use `initialize` only. Disabled instances do not run background probes.
197−An explicit `serverRefreshProviders` request with `refreshModels: true` calls the driver's
198−optional `refreshModels` operation. Antigravity opens a short-lived catalog session under the
199−instance's process admission guard, uses saved credentials, publishes models and commands,
200−then closes the process. An interactive login request fails with sign-in required. Web's
201−**Refresh provider status** and mobile's **Refresh models** actions request this operation.
202−Account access starts unknown and becomes authenticated after successful session setup,
203−including an explicit model refresh.
204−The [provider snapshot][antigravity-provider] takes models and commands from setup and native
205−updates. It preserves returned Gemini model IDs, labels, order, and thinking-level choices.
206−ACP `config_option_update` notifications and `session/set_config_option` responses replace
207−the instance's model catalog. Child session notifications do not change the root catalog.
208−The registry treats a successful empty catalog as authoritative and clears cached metadata
209−after sign-out. It must not retain a previous account's models. Cached models do not prove
210−current access. The auth response does not supply an email, plan tier, or reliable quota.
211−
212−Some upstream failures arrive as assistant text followed by `end_turn`. Preserve that message
213−without treating model-written text as a structured error or successful task completion.
214−
215−### Text generation
216−
217−[`AntigravityTextGeneration`][antigravity-text] implements titles, branch names, commit text,
218−and PR text through the same instance and Google sign-in. Each helper uses a temporary empty
219−workspace, no injected MCP servers, native `default` mode, and explicit denial of tools and
220−questions. Output is bounded, parsed against the existing schemas, and sanitized. Cancellation,
221−timeout, and sign-out close the process. Cleanup removes only that helper's verified temporary
222−native session files.
223−
224−The official agent has no verified hard no-tools setting. Global hooks and MCP configuration
225−can run before a prompt. Helpers check the profile's `config/hooks.json` and
226−`config/mcp_config.json` before launch and reject nonempty, malformed, or oversized
227−configuration. `supportsTextGeneration=false` keeps such an instance out of system-model
228−pickers. Empty managed profiles are supported. Do not describe prompt-time denial as a native
229−sandbox.
230−
231−## OpenCode server ownership and catalog
232−
233−Native OpenCode event logs skip text deltas and running tool snapshots. Pending, completed,
234−and failed tool states remain, including final output and errors. This filter does not change
235−events sent to clients or orchestration storage.
236−
237−Each OpenCode provider instance owns one lazy local server for catalog discovery and
238−text-generation helpers through [`OpenCodeServerOwner.ts`][opencode-server-owner]. Concurrent
239−borrowers share startup. The server closes 30 seconds after the last borrower releases it, or
240−when the provider instance closes. A failed or exited process can be started again on the next
241−use. An externally configured OpenCode server remains externally owned.
242−
243−The local server and its SDK clients use one resolved password. An explicit provider password
244−overrides `OPENCODE_SERVER_PASSWORD` in the spawned environment. Without an explicit password,
245−the client uses the password from the environment that the process inherits. External servers use
246−only their explicit provider password and never inherit the host's local password.
247−
248−Every server connection must pass the authenticated `/global/health` check before inventory or
249−session operations start. The response must contain a valid version at or above 1.14.19. Local
250−owners cache this result for the lifetime of the spawned process. External actions check once when
251−they create their server connection, not for each model or SDK request.
252−
253−Chat adapters keep their own server per thread. They register a thread-specific `t3-code` MCP
254−connection, while OpenCode stores MCP connections by directory. Sharing these chat servers
255−without changing MCP routing would let two threads in one directory replace each other's
256−connection.
257−
258−Chat adapters send the runtime mode as a session ruleset, but upstream OpenCode evaluates
259−doom-loop and subagent asks against the agent ruleset only. In full access the adapter answers
260−those asks itself so the user never sees an approval they already granted. It replies `once`
261−rather than `always` because OpenCode stores `always` grants per directory, and on a shared
262−external server that would widen what a supervised thread in the same directory may do.
263−
264−OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The
265−provider registry keeps the snapshot in memory and persists it in the existing per-instance cache.
266−Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the
267−OpenCode catalog from the current helper. The `serverRefreshProviders` request also refreshes it.
268−Periodic OpenCode probes remain disabled. OpenCode reads credentials for each inventory request,
269−but its native configuration files can remain cached for the lifetime of the helper process. The
270−helper closes 30 seconds after its last inventory or text-generation borrower releases it. A
271−refresh after that idle period starts a new helper and reads file changes. Repeated refreshes and
272−active text-generation work can extend process reuse. Changes to the provider configuration or
273−environment replace the instance and start a new discovery. Changes to unrelated settings only
274−update snapshot enrichment. Other providers retain their existing refresh policy.
275−
276−T3 Code does not own an external OpenCode process. Native configuration changes there can require
277−an external reload or restart before T3 Code's next refresh sees them.
278−
279−The shared server's idle shutdown does not clear the catalog. Failed discovery keeps the last
280−known models, slash commands, and skills through the registry's existing merge rules. A successful
281−empty inventory is authoritative. Existing threads keep their explicit model identifier and
282−options when catalog metadata is missing; the catalog is not permission to choose a different
283−model for a thread.
284−
285−## Model manifest
286−
287−The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
288−lists the current (non-legacy) model slugs per driver kind. The `ModelManifest` service
289−(`apps/server/src/provider/ModelManifest.ts`) refreshes that data from the same file on `main` via
290−raw.githubusercontent.com, so moving a model in or out of the legacy section is a commit, not a
291−release. Preference order is remote fetch, then the on-disk copy of the last successful fetch (in
292−the state directory), then the bundled copy. Fetches are TTL-gated, run concurrently with provider
293−probes, respect the `enableProviderUpdateChecks` setting, and never fail a provider check. The
294−Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`;
295−driver kinds absent from the manifest have no legacy concept.
296−
297−## Attachment access
298−
299−The server stores uploaded attachments in its attachment directory, outside the project workspace.
300−`ProviderService` adds the absolute path of each attachment to the turn text, then passes every
301−attachment to the provider adapter. Each adapter decides what its provider ingests natively:
302−
303−- Codex, Claude, Cursor, and Grok send images as native image inputs and skip generic files. For
304− these providers, generic files reach the agent only as file paths in the turn text.
305−- Antigravity sends BMP, JPEG, PNG, and WebP images and common audio formats as native blocks,
306− text files as embedded resources, and PDFs as resource links. Other files are rejected with an
307− error instead of being dropped. The session advertises the ACP client file system capability,
308− so workspace reads and writes come back through `fs/read_text_file` and `fs/write_text_file`
309− and are confined to the workspace and the attachments directory.
310−- OpenCode sends PNG/JPEG/GIF/WebP images, text files, and PDFs up to 20 MB as native file parts
311− with their real mime type. Everything else (ZIP and other binaries, image formats model APIs
312− reject, oversized files) falls back to the file path in the turn text, like the other providers.
313−- Antigravity sends BMP/JPEG/PNG/WebP images as native image blocks, UTF-8 text as embedded
314− resources, and PDFs as local resource links. Text is limited to 1 MiB per file, images to
315− 10 MiB each, and all attachments to 50 MiB per turn. Unsupported formats or oversized inputs
316− fail explicitly. Native path permissions still apply to PDFs.
317−
318−Claude receives the attachment directory as an allowed additional directory. Codex keeps its
319−configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode
320−allows all paths in full-access mode and requests approval for directories outside the workspace in
321−restricted modes. Cursor and Grok use their own provider permission rules.
322−
323−The server does not copy attachments into a project or bypass provider approval rules. If an agent
324−cannot read an attachment, the user must approve the access or select a runtime mode that permits it.
325−
326−Updated attachment schemas tolerate unknown attachment members, but old image-only clients still
327−cannot decode messages that contain file attachments. Client file-picking rollouts must account for
328−this limit.
329−
330−Do not run an old image-only server against state that contains file attachments. Replay decodes
331−each persisted event before projection. A file-bearing event can make `ProjectionPipeline` bootstrap
332−and `OrchestrationEngine` startup fail for the entire environment, not only the affected thread.
333−
334−## How provider work is requested
335−
336−Clients never call a provider directly. They dispatch orchestration commands over the RPC method
337−`orchestration.dispatchCommand`, defined with the rest of the orchestration surface in
338−[`orchestration.ts`][contracts]. The client-dispatchable provider-facing commands are
339−`thread.turn.start`, `thread.turn.interrupt`, `thread.approval.respond`,
340−`thread.user-input.respond`, `thread.checkpoint.revert`, and `thread.session.stop`, plus the mode
341−setters `thread.runtime-mode.set` and `thread.interaction-mode.set`.
342−
343−The engine persists an event for the command, and a server-side reactor performs the provider call.
344−Provider output comes back as internal commands such as `thread.message.assistant.delta` and
345−`thread.session.set`, which clients observe through `orchestration.subscribeThread`. See
346−[overview.md](./overview.md) for the command/event loop.
347−
348−## Server-side workers
349−
350−Provider work flows through three queue-backed workers. All three are built with
351−`makeDrainableWorker` from [`DrainableWorker.ts`][worker] and expose `drain` for deterministic test
352−synchronization.
353−
354−1. [`ProviderRuntimeIngestion`][ingest] consumes provider runtime streams and emits orchestration
355− commands.
356−2. [`ProviderCommandReactor`][cmd] reacts to orchestration intent events and dispatches provider
357− calls.
358−3. [`CheckpointReactor`][checkpoint] captures workspace checkpoints on turn start and completion, and
359− performs reverts.
360−
361−### Buffered assistant delivery
362−
363−A thread in `buffered` assistant delivery mode accumulates assistant text instead of streaming each
364−delta. The buffer is not held until turn completion. In [`ProviderRuntimeIngestion`][ingest],
365−`MAX_BUFFERED_ASSISTANT_CHARS` is 24,000: the append that would exceed it invalidates the buffer and
366−spills the whole accumulated text as one delta. The buffer also flushes at interaction boundaries,
367−when a request opens (approval) or user input is requested, via
368−`flushBufferedAssistantMessagesForTurn`.
369−
370−[drivers]: ../../apps/server/src/provider/builtInDrivers.ts
371−[codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts
372−[claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts
373−[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts
374−[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts
375−[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts
376−[antigravity]: ../../apps/server/src/provider/Drivers/AntigravityDriver.ts
377−[antigravity-adapter]: ../../apps/server/src/provider/Layers/AntigravityAdapter.ts
378−[antigravity-provider]: ../../apps/server/src/provider/Layers/AntigravityProvider.ts
379−[antigravity-installation]: ../../apps/server/src/provider/AntigravityInstallation.ts
380−[antigravity-release]: ../../apps/server/src/provider/antigravityRelease.ts
381−[antigravity-auth]: ../../apps/server/src/provider/AntigravityAuth.ts
382−[antigravity-auth-support]: ../../apps/server/src/provider/antigravityAuthSupport.ts
383−[antigravity-text]: ../../apps/server/src/textGeneration/AntigravityTextGeneration.ts
384−[provider-auth-service]: ../../apps/server/src/provider/Layers/ProviderAuthService.ts
385−[provider-setup]: ../../packages/contracts/src/providerSetup.ts
386−[opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts
387−[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
388−[instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts
389−[registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts
390−[service]: ../../apps/server/src/provider/Layers/ProviderService.ts
391−[contracts]: ../../packages/contracts/src/orchestration.ts
392−[worker]: ../../packages/shared/src/DrainableWorker.ts
393−[ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
394−[cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
395−[checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts
1+# Provider constraints
2+
3+Orchestration records intent and state without knowing which provider runs a thread. Provider
4+protocols, account ownership, permissions, and capabilities belong at the
5+[adapter boundary](../../apps/server/src/provider/Services/ProviderAdapter.ts). Normalize there
6+instead of spreading provider checks through reactors and clients.
7+
8+A driver kind identifies an integration; an instance identifies one configuration and account
9+lifecycle. Route work by instance, so two accounts using the same driver do not share mutable
10+session or catalog state.
11+
12+## Process and account isolation
13+
14+T3-managed OpenCode chat uses one server per thread. Its MCP registrations are directory-scoped, while
15+T3's MCP connection is thread-scoped. Sharing a chat server between threads in one directory would
16+let them replace each other's connection. Catalog and text-generation work can share the
17+[instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes
18+after an idle period. External OpenCode servers remain externally owned and can require an
19+external restart to pick up configuration changes.
20+
21+OpenCode also stores persistent approval grants per directory. Automatic full-access replies use
22+`once` so they cannot widen a supervised thread's permissions on a shared external server.
23+See the [adapter](../../apps/server/src/provider/Layers/OpenCodeAdapter.ts).
24+
25+Antigravity separates account profiles per instance while sharing installed executables across the
26+environment. It forces file-based credential storage because the native macOS keychain entry would
27+otherwise be shared across instances. The launch environment removes ambient Google credentials,
28+so an instance cannot silently use another account or billing project.
29+See [profile isolation](../../apps/server/src/provider/antigravityAuthSupport.ts).
30+
31+The [Antigravity installer](../../apps/server/src/provider/AntigravityInstallation.ts) outlives
32+client connections and provider-instance rebuilds. Releases are immutable, with an atomic pointer
33+selecting the version for new processes. Running processes hold leases on their version. Updates
34+and removal must respect those leases instead of replacing executables under a running agent.
35+
36+## Setup must not happen as a health-check side effect
37+
38+Opening a provider session can start MCP servers, run hooks, or launch a login browser.
39+[Grok probes](../../apps/server/src/provider/Layers/GrokProvider.ts) avoid authentication and
40+session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for
41+explicit setup or model refresh; background checks use initialization only.
42+
43+[Antigravity sign-in](../../apps/server/src/provider/AntigravityAuth.ts) belongs to the initiating
44+T3 auth session. The client carries the return URL back to the environment because the provider's
45+loopback listener may be on another machine. Forward only the callback for the owned pending flow;
46+a successful callback HTTP request is not proof that provider authentication finished. The native
47+process owns token exchange and storage.
48+
49+Antigravity sign-out closes admission to new processes and stops existing processes before clearing account
50+metadata. Otherwise a helper or resumed session could retain the old account. Cached model lists
51+do not establish current access, and an authoritative empty catalog must clear the old list.
52+
53+Antigravity text-generation helpers deny tool requests, but native hooks and MCP configuration can
54+run before the prompt. They reject profiles with such configuration before launch. Prompt
55+instructions and tool denial do not create a native sandbox.
56+See [helper constraints](../../apps/server/src/textGeneration/AntigravityTextGeneration.ts).
57+
58+## Protocol traps
59+
60+Codex async questions arrive as notifications and are answered with a new user message. There is
61+no pending RPC response to send. Blocking questions still use the request/response path. The
62+[adapter](../../apps/server/src/provider/Layers/CodexAdapter.ts) distinguishes them; the
63+[decider](../../apps/server/src/orchestration/decider.ts) records an async answer and its user
64+message together.
65+
66+An async question can outlive the turn or a server restart. The engine reads that request's
67+durable activity before resolving it because the in-memory command snapshot omits old activities.
68+Do not infer that a request has disappeared merely because it is outside the recent window.
69+
70+Capabilities must describe what the provider can actually do. Antigravity can capture workspace
71+checkpoints but cannot roll back its conversation. The [checkpoint boundary](./overview.md#turn-completion-and-checkpoints)
72+therefore rejects revert before touching files. Native permission and question option IDs must
73+also survive normalization; a display label is not necessarily a valid reply.
74+
75+## Attachments and stored history
76+
77+Attachments live outside the project workspace. [ProviderService](../../apps/server/src/provider/Layers/ProviderService.ts)
78+puts their environment-local paths in turn input and lets adapters choose native input formats.
79+A path in the prompt does not grant filesystem access. Keep provider sandbox and approval rules
80+in force; copying uploads into the project to bypass them changes that boundary.
81+
82+File attachments introduced a replay compatibility limit. Image-only clients cannot decode
83+file-bearing messages, and an image-only server can fail the entire environment's startup when
84+replaying one such event. Rollouts and downgrades must account for persisted history as well as
85+current client support.
86+
87+Model classification has its own [manifest constraints](./model-manifest.md). Assistant-reference
88+handling is documented under [citations](./assistant-citations.md).
docs/internals/remote.md+59−256
@@ -1,256 +1,59 @@
1−# Remote Architecture
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−Remote environments are shipped, not planned. Direct, bearer-paired, relay-tunneled, Tailscale, and
6−desktop-managed SSH access all exist today. This document describes the model they share and where
7−each piece lives. For the user-facing setup guide see
8−[remote access](../user/remote-access.md).
9−
10−## The model
11−
12−T3 has one runtime boundary: a client talks to a T3 server over HTTP and WebSocket, and the server
13−owns orchestration, providers, terminals, git, and filesystem operations. Remoteness is expressed at
14−the connection layer, never by splitting the runtime.
15−
16−```text
17−┌──────────────────────────────────────────────┐
18−│ Client (desktop / mobile / web) │
19−│ known environments, connection supervisor │
20−└───────────────┬──────────────────────────────┘
21− │ resolves one access endpoint
22−┌───────────────▼──────────────────────────────┐
23−│ Access method │
24−│ direct ws/wss, relay tunnel, │
25−│ Tailscale serve, desktop-managed ssh │
26−└───────────────┬──────────────────────────────┘
27− │ connects to one T3 server
28−┌───────────────▼──────────────────────────────┐
29−│ Execution environment = one T3 server │
30−│ identity, providers, projects/threads, │
31−│ terminals, git, filesystem │
32−└──────────────────────────────────────────────┘
33−```
34−
35−### ExecutionEnvironment
36−
37−One running T3 server instance. It owns provider availability and auth, model availability, projects
38−and threads, terminal processes, filesystem access, git operations, and server settings.
39−
40−It is identified by a stable `environmentId`, persisted by the server at `<stateDir>/environment-id`
41−and generated on first start (`apps/server/src/environment/ServerEnvironment.ts`). Desktop, mobile,
42−and web all reason about the same concept.
43−
44−Initialization publishes a complete ID atomically. Empty or whitespace-only ID files are repaired
45−using a retained `<stateDir>/environment-id.recovery` file so concurrent and delayed repairs choose
46−the same ID. Existing nonempty ID files remain authoritative.
47−
48−### Known environments and connection targets
49−
50−A saved client-side entry for an environment the client knows how to reach. It is not
51−server-authored; it is local to a device or client profile. In the hosted web app these entries are
52−browser-local. A hosted pairing URL can create one, but it does not give the hosted app a server-side
53−control plane or a copy of session state.
54−
55−[`connection/model.ts`][model] defines four target tags, which are the real access taxonomy:
56−
57−| Target | Used for |
58−| ------------------------- | ------------------------------------------------------------------------ |
59−| `PrimaryConnectionTarget` | The platform-managed local server (desktop backend, CLI-served web app). |
60−| `BearerConnectionTarget` | Any manually paired endpoint reached over direct HTTP/WebSocket. |
61−| `RelayConnectionTarget` | Managed T3 Connect relay tunnels. |
62−| `SshConnectionTarget` | Desktop-managed SSH environments. |
63−
64−Bearer, relay, and SSH are persisted; primary is platform-managed. Note that Tailscale is not a
65−separate target kind. A Tailscale URL is paired through the ordinary bearer path in
66−[`onboarding.ts`][onboarding] (`preparePairingRegistration`), which accepts either a pairing URL or a
67−host plus pairing code. Tailscale is an endpoint provider and transport, not a distinct runtime
68−concept.
69−
70−### AdvertisedEndpoint
71−
72−A server- or desktop-authored candidate endpoint for an environment: a concrete HTTP and WebSocket
73−base URL pair, a default/available/unavailable marker, reachability hints (loopback, LAN, private,
74−public, tunnel), and compatibility hints such as whether the hosted HTTPS app can use it.
75−
76−Clients treat advertised endpoints as hints, not proof that a route works from the current device.
77−The connection attempt decides.
78−
79−The UI shows one default endpoint in the network-access summary and keeps the rest behind an advanced
80−list. `selectPairingEndpoint` in
81−[`ConnectionsSettings.tsx`](../../apps/web/src/components/settings/ConnectionsSettings.tsx) excludes
82−unavailable endpoints and then picks, in order:
83−
84−1. the saved `defaultEndpointKey` override;
85−2. the first endpoint marked `isDefault`;
86−3. the first endpoint whose reachability is not `loopback`;
87−4. the first endpoint compatible with the hosted HTTPS app;
88−5. otherwise nothing.
89−
90−There is no unconditional loopback fallback. A loopback endpoint only wins through an explicit saved
91−override or `isDefault`. Persist the override by stable endpoint kind rather than raw URL where
92−possible, since LAN addresses change with networks; Tailscale endpoints use provider-specific stable
93−keys (`tailscale-ip:`, `tailscale-magicdns:`).
94−
95−### Endpoint providers
96−
97−Endpoint providers contribute advertised endpoints without becoming part of the core environment
98−model: core owns environments, pairing, and connection lifecycle, and providers return normalized
99−`AdvertisedEndpoint` records.
100−
101−Tailscale is the first provider, and T3 manages more than discovery. When `tailscaleServeEnabled` is
102−set, the server acquires a Tailscale serve mapping for its actual listening port at startup with
103−`ensureTailscaleServe` and releases it with `disableTailscaleServe` on scope close
104−(`apps/server/src/server.ts`, using [`@t3tools/tailscale`](../../packages/tailscale/src/tailscale.ts)).
105−Endpoint identifiers are synthesized in `apps/desktop/src/backend/tailscaleEndpointProvider.ts` with
106−`private-network` reachability.
107−
108−### Hosted pairing request
109−
110−A hosted pairing request is a bootstrap URL for the static web app, not a transport:
111−
112−```text
113−https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE
114−```
115−
116−The hosted app reads `host`, takes the token from the URL hash, exchanges it directly with that
117−backend, strips the token from browser history, and saves the environment record locally. Helpers
118−live in [`shared/remote.ts`](../../packages/shared/src/remote.ts) (`setPairingTokenOnUrl`,
119−`getPairingTokenFromUrl`, `stripPairingTokenFromUrl`) and `apps/web/src/hostedPairing.ts`.
120−
121−Constraints:
122−
123−- the hosted app does not proxy HTTP or WebSocket traffic;
124−- the backend must be directly reachable from the browser;
125−- HTTPS pages can only reach HTTPS/WSS backends;
126−- HTTP LAN endpoints keep using direct desktop or CLI pairing URLs;
127−- the token belongs in the hash so it is never sent to the hosted app origin.
128−
129−### RepositoryIdentity and Project
130−
131−`RepositoryIdentity` is a best-effort logical repo grouping across environments, used for UI grouping
132−and correlation only, never for routing. `Project` remains environment-local: a local clone and a
133−remote clone are different projects that may share a `RepositoryIdentity`, and threads bind to one
134−project in one environment.
135−
136−## Access methods
137−
138−Access answers one question: how does the client speak WebSocket to a T3 server? It does not answer
139−how the server got started or who manages the process.
140−
141−### Direct WebSocket access
142−
143−`wss://t3.example.com` or `ws://10.0.0.15:3773`, paired as a bearer target. This is the base model.
144−It works for desktop, mobile, and web with no client-side process management. Browser security rules
145−are part of it: a hosted HTTPS client cannot connect to plain `ws://` or `http://` LAN backends.
146−
147−### Relay-tunneled access
148−
149−Managed T3 Connect relay tunnels use `RelayConnectionTarget` and are the answer when the host is
150−behind NAT, inbound ports are unavailable, or mobile must reach a desktop-hosted environment. From
151−the client's perspective this is still an ordinary WebSocket connection; the route is mediated. The
152−relay Worker only brokers credentials and a managed endpoint; application traffic then flows over
153−the provisioned Cloudflare tunnel hostname for the life of the connection, not through the relay
154−Worker itself. See [t3-connect.md](./t3-connect.md).
155−
156−### Tailscale access
157−
158−A T3-managed `tailscale serve` mapping exposes the server on the tailnet over HTTPS, and the
159−resulting private-network endpoints are advertised for pairing. Connection then follows the ordinary
160−bearer path.
161−
162−### Desktop-managed SSH access
163−
164−SSH is an access and launch helper, not a separate environment type. `DesktopSshEnvironment`
165−([apps/desktop/src/ssh/DesktopSshEnvironment.ts][sshenv]) exposes `discoverHosts`,
166−`ensureEnvironment`, and `disconnectEnvironment`. It discovers targets from SSH config and known
167−hosts, owns password/askpass prompts, and delegates lifecycle to `SshEnvironmentManager` in
168−[packages/ssh/src/tunnel.ts][sshtunnel], which resolves the target, launches or reuses the remote T3
169−server, opens a local tunnel, checks HTTP readiness, optionally issues a remote pairing token, and
170−returns local HTTP/WS endpoints. Disconnect closes the tunnel and stops the remote server if the
171−launcher started it; a server that was already running (marked `external`) is left running.
172−
173−The desktop main process owns this because it can spawn SSH, manage prompts, write launch scripts,
174−and clean up forwards. The renderer connects through the forwarded URL like any other environment and
175−needs no SSH-specific RPC path.
176−
177−Failure handling is explicit: SSH auth failure surfaces before an environment is saved, remote launch
178−failure includes launcher output where available, forwarded-port failure leaves the environment
179−disconnected rather than falling back to an unrelated endpoint, and reconnect restores the SSH bridge
180−before reconnecting the WebSocket client.
181−
182−## Launch methods
183−
184−Launch answers a different question: how does a T3 server come to exist on the target machine? Keep
185−it separate from access.
186−
187−- **Pre-existing server.** The operator already runs T3 and the client connects directly or through a
188− tunnel.
189−- **Desktop-managed remote launch over SSH.** Desktop probes the machine, launches or reuses a remote
190− server, forwards a port, and the renderer connects normally. The saved environment records that it
191− came from SSH launch for reconnect and lifecycle UX only; that metadata never changes the protocol
192− or the identity model.
193−- **Client-managed local publish.** A local server is published through the relay with
194− `t3 connect link`, exposing a desktop-hosted environment to mobile without router or firewall
195− changes.
196−
197−The same `ExecutionEnvironment` can be reached several of these ways. Only the launch and access
198−paths differ.
199−
200−## Security model
201−
202−Some environments are reachable over untrusted networks, so remote-capable environments require
203−explicit authentication, tunnel exposure never relies on obscurity, and saved endpoints carry enough
204−auth metadata to reconnect safely.
205−
206−WebSocket authentication is a dedicated short-lived ticket, not a token in a query string. The client
207−presents its long-lived bearer or DPoP credential in HTTP headers to
208−`POST /api/auth/websocket-ticket` ([authorization/remote.ts][authremote]), and appends only the
209−returned ticket as `wsTicket` on the socket URL. The server issues it through
210−`EnvironmentAuth.issueWebSocketTicket`; tickets are tagged `kind: "websocket"` and default to a
211−five-minute TTL (`DEFAULT_WEBSOCKET_TOKEN_TTL` in `apps/server/src/auth/SessionStore.ts`). The
212−handshake verifies the ticket, and each RPC method still enforces its own scope. See
213−[environment-auth.md](./environment-auth.md).
214−
215−Hosted pairing is a client-side convenience only. The hosted app must not receive pairing tokens
216−through query parameters, must not store pairing state server-side, and must not imply that an HTTP
217−backend is reachable from an HTTPS browser context.
218−
219−## Version coordination
220−
221−Remote environments stay online while clients move to newer releases. The environment descriptor
222−carries the running server version and may advertise a safe replacement path, so the UI can show the
223−right action without making the transport responsible for process management. The connection
224−supervisor owns the resulting disconnect and reconnect like any other involuntary close. See
225−[server-updates.md](./server-updates.md).
226−
227−## Machine identity
228−
229−Every environment is drawn with one of a fixed set of machine glyphs (`EnvironmentMachineKind` in
230−contracts: server, cloud, desktop, laptop, mac-mini, mac-studio). Two inputs feed it, and the
231−precedence lives in one helper, `resolveEnvironmentMachineKind`, so web and mobile cannot drift:
232−
233−1. `settings.environmentIcon`, a nullable server setting the user picks in Settings → Connections.
234− It is server state rather than client state so every device connecting to that server agrees.
235−2. `environment.platform.machine`, detected once at startup by [ServerEnvironmentMachine.ts][machine]
236− (IOKit product name / `hw.model` on macOS, `/sys/class/dmi/id` on Linux with virtualization
237− markers mapped to "cloud"). Absent when there is no signal, so older servers, Windows hosts, and
238− containers decode without it.
239−3. A generic server otherwise.
240−
241−Clients treat the field like any other capability: absent means "use the fallback", never "wait".
242−
243−## Future work
244−
245−These remain unbuilt and are listed to keep the model honest:
246−
247−- third-party tunnel products as additional endpoint providers;
248−- a relay-hosted OAuth callback broker (see [t3-connect.md](./t3-connect.md));
249−- richer multi-environment UI beyond the current connections list.
250−
251−[model]: ../../packages/client-runtime/src/connection/model.ts
252−[onboarding]: ../../packages/client-runtime/src/connection/onboarding.ts
253−[authremote]: ../../packages/client-runtime/src/authorization/remote.ts
254−[sshenv]: ../../apps/desktop/src/ssh/DesktopSshEnvironment.ts
255−[sshtunnel]: ../../packages/ssh/src/tunnel.ts
256−[machine]: ../../apps/server/src/environment/ServerEnvironmentMachine.ts
1+# Remote architecture
2+
3+Each connection joins a client to one environment over HTTP and WebSocket. The
4+environment owns providers, execution, files, and durable state. Direct access,
5+Tailscale, SSH, and T3 Connect change how the client reaches that server; they do
6+not introduce another execution model. See
7+[remote access](../user/remote-access.md) for setup.
8+
9+## Identity is independent of the route
10+
11+An environment keeps its ID across server restarts and endpoint changes. Saved
12+connections are local to a client profile; the server's identity and state are
13+not. A repository identity can correlate clones across environments, but never
14+routes work between them. A project and its threads belong to one environment.
15+
16+[Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts)
17+must publish a complete ID atomically. Repair of an empty ID file retains a
18+recovery file so concurrent or delayed initializers choose the same winner.
19+Removing that recovery state as ordinary temporary-file cleanup can change the
20+identity underneath an already-running server.
21+
22+Advertised endpoints are reachability hints. Only the connecting device can
23+prove that a route works. In particular, a host's loopback address refers to a
24+different machine when another device opens it. Endpoint selection must not
25+silently fall back to loopback when a shareable endpoint is unavailable.
26+
27+## Hosted web is a client
28+
29+The hosted web app stores its connection catalog in the browser and connects
30+directly to each environment. It does not proxy traffic or hold server-side
31+pairing state. Hosting the UI over HTTPS therefore cannot make a plain HTTP LAN
32+backend accessible from that browser context.
33+
34+A [hosted pairing URL](../../apps/web/src/hostedPairing.ts) identifies the backend
35+in its query and carries the pairing secret in its fragment. Fragments stay out
36+of requests to the hosted origin. The browser exchanges the secret with the
37+environment and strips it from its history. Moving the token into a query
38+parameter would disclose it to the wrong origin.
39+
40+## Access and process ownership are different
41+
42+Tailscale supplies an endpoint for ordinary pairing, so it needs no separate
43+environment type. Authentication remains the environment's responsibility for
44+every route. See [environment authentication](./environment-auth.md) and the
45+[T3 Connect trust boundary](./t3-connect.md).
46+
47+SSH can launch a server as well as forward a port. Desktop main owns that
48+lifecycle because it can spawn SSH and handle authentication prompts. The
49+renderer uses the forwarded endpoint through the shared connection runtime.
50+[SSH cleanup](../../packages/ssh/src/tunnel.ts) stops a remote server only if the
51+launcher owns it; a server it discovered already running must survive a client
52+disconnect. Reconnection restores the forward before opening the application
53+transport.
54+
55+Remote servers can outlive several client releases. Clients must use advertised
56+capabilities and handle their absence, rather than assume their own version
57+describes the server. Process replacement belongs to the launcher's
58+[update protocol](./server-updates.md); the connection runtime handles the
59+resulting disconnect.
docs/internals/resource-telemetry.md+50−394
@@ -1,394 +1,50 @@
1−# Resource telemetry architecture
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−Status: implemented
6−
7−## Purpose
8−
9−Resource telemetry replaces recurring `ps`, PowerShell, `ioreg`, and `pmset`
10−subprocess probes with two persistent, direct data sources:
11−
12−1. a standalone Rust resource-monitor executable that reads process counters
13− through operating-system APIs via `sysinfo`;
14−2. Electron main-process APIs for Electron process metrics and host power state.
15−
16−The native monitor owns bounded in-memory history. The server only merges and
17−summarizes that history when diagnostics requests it. Telemetry history is not
18−persisted to disk or continuously copied into Node.
19−
20−## Why a standalone executable
21−
22−The monitor is intentionally not a Node native addon.
23−
24−- No N-API, `ffi-rs`, or dynamic-library ABI is loaded into the server process.
25−- A monitor crash cannot corrupt the Node runtime.
26−- The server can supervise, restart, version-check, and measure the monitor as a
27− normal child process.
28−- The same protocol works for the desktop app and the published CLI.
29−- Packaging is a single platform executable instead of an addon toolchain plus
30− Node/Electron ABI matrix.
31−
32−The cost is one persistent child process and NDJSON serialization. That is a
33−better failure boundary than repeatedly spawning shell utilities or loading
34−native code into Node.
35−
36−## Runtime topology
37−
38−### Desktop
39−
40−```text
41−Electron main
42− ├─ powerMonitor
43− ├─ app.getAppMetrics() while diagnostics is open
44− ├─ inherited fd 4, telemetry NDJSON ─────────────┐
45− └─ inherited fd 5, demand-control NDJSON ◀──────┤
46− ▼
47−Node server ── stdin/stdout NDJSON ── Rust resource monitor
48− │
49− ├─ ResourceTelemetry Effect service
50− ├─ background power policy projection
51− └─ WebSocket RPC/subscription ── diagnostics UI
52−```
53−
54−### Web, headless, and remote server
55−
56−Electron telemetry is unavailable. The native monitor still runs beside the
57−server and tracks the server process tree. Power fields degrade to `unknown`
58−instead of invoking platform shell commands.
59−
60−### WSL backend limitation
61−
62−Windows desktop packages currently ship the Windows resource-monitor executable.
63−That executable cannot run inside the Linux WSL backend, so a WSL-only backend
64−does not receive `resourceMonitorPath` and reports native process telemetry as
65−unavailable. Electron host-power telemetry remains available over the inherited
66−desktop pipe. Supporting native WSL process telemetry requires publishing a
67−Linux sidecar for each supported architecture in the Windows artifact and
68−converting its packaged path into the selected distro; the configuration
69−deliberately does not pass the Windows `.exe` into WSL as a false fallback.
70−
71−## Native monitor
72−
73−The executable lives in `native/resource-monitor`.
74−
75−It receives schema-compatible commands on stdin and emits one JSON object per
76−line on stdout:
77−
78−- `configure`
79−- `setExternalProcesses`
80−- `setSampleInterval`
81−- `setStreaming`
82−- `sampleNow`
83−- `readHistory`
84−- `shutdown`
85−- `hello`
86−- `snapshot`
87−- `historyChunk`
88−- `error`
89−
90−The protocol version is defined by
91−`RESOURCE_MONITOR_PROTOCOL_VERSION` in
92−`packages/contracts/src/resourceTelemetry.ts`.
93−
94−### Collection
95−
96−The monitor keeps one `sysinfo::System` instance and refreshes it at the
97−power-adaptive interval selected by the server. It collects:
98−
99−- PID and parent PID;
100−- process start time and run time;
101−- process name and command line;
102−- current and cumulative CPU usage;
103−- resident and virtual memory;
104−- cumulative process I/O counters.
105−
106−On Linux, task/thread enumeration is disabled. Command lines are refreshed with
107−each sample so process replacements remain visible. Disabling task enumeration
108−avoids walking every `/proc/<pid>/task/<tid>` directory on each refresh.
109−
110−### Process-tree selection
111−
112−Each sample scans the accessible process table, builds the PID/PPID graph, and
113−retains:
114−
115−- the server process;
116−- every descendant of the server, including provider-spawned grandchildren such
117− as shells, `node`, `tsgo`, language servers, and other tools;
118−- Electron processes supplied as explicit external roots;
119−- descendants of those Electron roots;
120−- the resource monitor itself, because it is a server child.
121−
122−Process identity is `(pid, startTimeMs)`, not PID alone. Electron and native
123−start times are matched with a two-second tolerance because native start times
124−can have coarser platform resolution.
125−
126−The process list is emitted in depth-first tree order so renderer collapse and
127−expansion preserves complete subtrees.
128−
129−### Native history and streaming
130−
131−Every native sample is appended to a one-hour in-memory ring bounded to 3,600
132−snapshots, 20,000 retained process rows, and 64 MiB of retained history bytes.
133−History stays in the sidecar until a `readHistory` request and is returned in
134−bounded chunks. The first bound reached wins, so high process counts or large
135−process names and command lines shorten the effective history window.
136−
137−Periodic snapshot streaming is disabled by default. The server enables it only
138−while at least one diagnostics subscription is retained. `sampleNow` remains
139−available for explicit refreshes and identity validation.
140−
141−The server adjusts native sampling without restarting the sidecar:
142−
143−- suspended, locked, low-power, or serious/critical thermal state: 15 seconds;
144−- battery: 5 seconds;
145−- normal AC: 5 seconds in the background and 1 second while live diagnostics is
146− open;
147−- unknown or stale power: 5 seconds in the background and 1 second while live
148− diagnostics is open.
149−
150−### Sampling limits
151−
152−This is counter sampling, not syscall tracing.
153−
154−- A process that starts and exits entirely between samples may not be observed.
155−- Cumulative CPU and I/O counters still provide accurate deltas for processes
156− that survive across samples.
157−- Exact file paths, individual write syscalls, ETW events, eBPF events, and
158− Endpoint Security events are outside this implementation.
159−
160−Those deeper tracing systems can be added later as opt-in diagnostic modes
161−without changing the public `ResourceTelemetry` model.
162−
163−## I/O semantics
164−
165−The monitor preserves platform semantics instead of presenting all counters as
166−equivalent:
167−
168−- Unix-like platforms report storage I/O counters exposed by `sysinfo`.
169−- Windows reports all process I/O bytes, not only disk bytes.
170−- Operating-system caches can prevent logical application reads or writes from
171− appearing as physical storage bytes.
172−
173−The UI therefore labels these values as I/O reads and writes and exposes the
174−per-process `ioSemantics` value.
175−
176−Group totals are observed deltas since telemetry startup. Per-process total
177−columns are the operating system's cumulative counters for that process.
178−
179−## Electron telemetry
180−
181−Electron main owns `DesktopTelemetryPublisher`.
182−
183−Power events trigger an immediate snapshot. While diagnostics is closed, the
184−server sends the configured active and idle host-power intervals to Electron
185−(30 seconds and 2 minutes in the balanced profile). During those heartbeats
186−Electron reads:
187−
188−- `powerMonitor.isOnBatteryPower()`;
189−- `powerMonitor.getSystemIdleTime()`;
190−- `powerMonitor.getSystemIdleState()`;
191−- `powerMonitor.getCurrentThermalState()`.
192−
193−`app.getAppMetrics()` is only called while diagnostics demand is active. Its
194−live cadence is 1 second on AC, 5 seconds on battery, and 15 seconds while
195−locked, suspended, or thermally constrained.
196−
197−It also listens for:
198−
199−- lock and unlock;
200−- suspend and resume;
201−- AC and battery transitions;
202−- thermal-state changes;
203−- CPU speed-limit changes.
204−
205−Suspension stays latched across event-driven snapshots. An explicit resume
206−clears it immediately; a periodic heartbeat also clears a stale latch so a
207−missed resume event cannot leave telemetry permanently constrained.
208−
209−Electron does not expose a cross-platform low-power-mode getter, so that field
210−remains `unknown`.
211−
212−The desktop backend is spawned with:
213−
214−- fd 3 for the existing bootstrap payload;
215−- fd 4 for Electron-to-server telemetry NDJSON;
216−- fd 5 for server-to-Electron diagnostics-demand NDJSON.
217−
218−These are private Electron-main/server pipes. They do not use the renderer
219−WebSocket and are recreated for every backend restart.
220−
221−## Server Effect services
222−
223−The implementation is under `apps/server/src/resourceTelemetry`.
224−
225−### `ResourceMonitorBinary`
226−
227−Resolves an executable from:
228−
229−1. `T3CODE_RESOURCE_MONITOR_PATH`;
230−2. desktop bootstrap configuration;
231−3. bundled CLI resources;
232−4. local Cargo build outputs.
233−
234−Unsupported platforms, missing binaries, and non-executable binaries use
235−schema-backed tagged errors with descriptive messages.
236−
237−### `NativeTelemetryClient`
238−
239−Owns the resource-monitor process and protocol.
240−
241−- validates the hello/version handshake;
242−- sends configuration and external process roots;
243−- adapts the native interval from host power state;
244−- enables streaming only for scoped live subscribers;
245−- reads chunked native history on demand;
246−- exposes `sampleNow`;
247−- serializes commands;
248−- supervises process exit and protocol failure;
249−- restarts with bounded exponential backoff;
250−- opens a circuit after repeated failures;
251−- supports explicit retry;
252−- publishes health changes immediately.
253−
254−Snapshot sequence numbers are scoped to a monitor generation. Server ingestion
255−uses the monitor restart count as the generation key, so sequence reset after a
256−restart cannot freeze telemetry.
257−
258−### `DesktopTelemetryReceiver`
259−
260−Reads fd 4, decodes schema-validated messages, stores the latest Electron
261−snapshot, and publishes desktop health. It writes diagnostics demand to fd 5
262−and gives the first sample a 90-second startup deadline. Once samples arrive,
263−the stale deadline stays beyond the slower configured host-power interval with
264−30 seconds of scheduling grace, so intentional 2–10 minute idle polling does
265−not oscillate the policy between constrained and unconstrained states. Decode
266−errors, protocol mismatch, control-write failure, stream failure, stale input,
267−and normal stream closure are represented explicitly.
268−
269−### `ResourceTelemetry`
270−
271−Merges native and Electron data and owns public telemetry semantics.
272−
273−- calculates CPU and I/O rates from cumulative native counters;
274−- preserves the last native rates during desktop-only updates;
275−- classifies backend, Electron, and monitor processes;
276−- computes process depth and child relationships;
277−- tracks starts, exits, CPU time, and observed I/O;
278−- projects power data;
279−- acquires native streaming and Electron process metrics only for scoped live
280− subscribers;
281−- queries and replays native history only when requested;
282−- validates `(pid, startTimeMs)` before process signaling;
283−- updates history health even when no further native sample arrives.
284−
285−Electron and monitor processes are visible but are not valid targets for the
286−existing process-signal RPC.
287−
288−### History projection
289−
290−`ResourceTelemetryHistory` is a pure on-demand projection. It replays raw native
291−snapshots to derive rates, lifecycle counters, buckets, and process summaries.
292−Current Electron process metrics are intentionally excluded from historical
293−replay so they cannot overwrite older native CPU or memory samples.
294−
295−### `ResourceAttribution`
296−
297−Tracks known logical application I/O separately from OS counters. Current
298−integration points record successful writes for:
299−
300−- provider native and canonical event logs;
301−- the local server trace sink.
302−
303−Entries contain component, operation, logical bytes, count, and elapsed time.
304−Future persistence paths should call `ResourceAttribution.record` rather than
305−adding diagnostics-specific counters.
306−
307−## Background policy integration
308−
309−`HostPowerMonitor` consumes `DesktopTelemetryReceiver` directly; observing host
310−power does not retain live resource diagnostics or invoke shell probes.
311−
312−The monitor updates its latest timestamp on every Electron sample but only
313−publishes semantic state changes. Increasing idle seconds alone does not cause a
314−background-policy broadcast every second.
315−
316−## Public API and UI
317−
318−The WebSocket RPC surface provides:
319−
320−- current snapshot;
321−- bounded history;
322−- explicit monitor retry;
323−- a live snapshot subscription.
324−
325−The diagnostics page displays:
326−
327−- aggregate CPU, memory, I/O, and process counts;
328−- backend, Electron, and monitor overhead groups;
329−- power and thermal state;
330−- collector health and restart information;
331−- CPU and I/O history;
332−- a collapsible live process tree;
333−- safe process signaling for backend descendants;
334−- instrumented logical application I/O.
335−
336−Legacy process diagnostics RPCs are projected from the same service so they no
337−longer start recurring process-table commands.
338−
339−## Packaging
340−
341−Desktop artifact builds compile the Rust target, stage it as
342−`resources/resource-monitor/t3-resource-monitor[.exe]`, and pass its path to the
343−backend bootstrap.
344−
345−CLI release jobs upload each active platform monitor artifact and copy it into:
346−
347−```text
348−apps/server/dist/resource-monitor/<platform>-<arch>/
349−```
350−
351−The published server package already includes `dist`, so those executables ship
352−with the CLI. Missing platform artifacts degrade native telemetry to
353−`unavailable`; the server continues running.
354−
355−## Resource and failure behavior
356−
357−Steady state uses:
358−
359−- one native process;
360−- power-adaptive native counter sampling with no periodic Node snapshot stream;
361−- event-driven Electron power updates plus profile-driven heartbeats (30–60
362− seconds while active and 2–10 minutes while idle);
363−- no `app.getAppMetrics()` calls while diagnostics is closed;
364−- no telemetry database;
365−- no recurring shell probes;
366−- bounded PubSub queues and native ring history.
367−
368−The diagnostics page exposes the monitor's own process resource usage and
369−collection duration so the observer's cost is measurable.
370−
371−Failures are isolated:
372−
373−- native failure does not stop the server;
374−- Electron telemetry loss does not stop native telemetry;
375−- schema/version errors are visible in health;
376−- repeated native failures stop automatic restart churn until explicit retry;
377−- server and desktop shutdown close their respective streams and child process
378− scopes.
379−
380−## Future integration points
381−
382−High-value follow-up work can use the existing service boundaries:
383−
384−- opt-in file-path attribution through platform-specific tracing;
385−- process lifecycle events to reduce the chance of missing very short-lived
386− children;
387−- additional `ResourceAttribution` instrumentation for databases, checkpoints,
388− caches, and file synchronization;
389−- exported diagnostic bundles;
390−- adaptive sample intervals based on diagnostics visibility and active work.
391−
392−These additions should preserve the current rules: direct platform APIs,
393−schema-validated boundaries, explicit metric semantics, bounded retention, and
394−no mandatory telemetry persistence.
1+# Resource telemetry
2+
3+Process counters come from a [standalone Rust monitor](../../native/resource-monitor/src/main.rs)
4+using `sysinfo`. Electron main supplies host power and Electron process metrics.
5+Keeping native collection outside Node isolates collector crashes and avoids a
6+Node/Electron addon ABI matrix. Desktop and CLI servers use the same child-process
7+protocol. A missing or failed collector leaves the server running.
8+
9+## Collection cost
10+
11+The native child owns sampling and bounded in-memory history. The server requests
12+continuous snapshots only while diagnostics has live subscribers and fetches
13+history on demand. Consuming host power for background scheduling must not retain
14+live diagnostics. There is no telemetry database or recurring shell-probe fallback.
15+
16+History has independent bounds for age, snapshot count, process rows, and retained
17+bytes. A count limit alone cannot bound memory when command lines vary in size.
18+Large process trees therefore shorten the available history window. Linux task
19+enumeration is disabled because walking every `/proc/<pid>/task/<tid>` directory
20+makes sampling itself expensive.
21+
22+Electron power updates travel over private inherited pipes, independent of the
23+renderer connection. Power events and slow heartbeats continue with diagnostics
24+closed; `app.getAppMetrics()` runs only on live demand. The receiver's stale deadline
25+must exceed the slowest configured heartbeat plus scheduling grace, or intentional
26+idle polling makes background policy oscillate between constrained and
27+unconstrained states. Headless servers leave unavailable power data unknown.
28+
29+## Measurement traps
30+
31+- Process identity includes start time because operating systems reuse PIDs.
32+ Electron and native start times have different precision, so merging allows a
33+ small tolerance. Process signaling rechecks the native identity with a fresh
34+ sample.
35+- Snapshot sequence numbers belong to a monitor generation. Comparing them across
36+ restarts would discard the new monitor's samples until its sequence caught up.
37+- Sampling can miss a process that starts and exits between samples. Cumulative
38+ counters still yield deltas for processes observed across samples.
39+- Windows process I/O includes more than disk traffic. Unix counters report storage
40+ I/O, which can differ from logical application reads and writes because of OS
41+ caching. Keep instrumented logical I/O separate from these counters.
42+- Group totals accumulate observed deltas since telemetry started. Per-process
43+ cumulative counters cover the operating system's lifetime for that process.
44+- Historical replay uses native samples without current Electron CPU or memory
45+ metrics. Merging the latest Electron values would overwrite the past.
46+
47+A WSL backend needs a Linux monitor even though Electron runs on Windows. Windows
48+desktop packages currently supply only the Windows executable, so native process
49+telemetry for the WSL backend is unavailable. The inherited Electron power feed
50+still works.
docs/internals/scripts.md+0−215
@@ -1,215 +0,0 @@
1−# Scripts
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−## First checkout
6−
7−T3 Code uses [Vite+](https://viteplus.dev/guide/). Install the global `vp` command, install
8−dependencies, then start the dev stack:
9−
10−```bash
11−curl -fsSL https://vite.plus | bash # Windows: irm https://vite.plus/ps1 | iex
12−vp i
13−vp run dev
14−```
15−
16−Node 24 is required. Bun is not: the server picks Bun adapters when it detects Bun and falls back to
17−Node otherwise, and nothing in contributor setup needs it.
18−
19−`vp run dev` prints a one-time pairing URL. Open it so the first browser navigation is
20−authenticated.
21−
22−## Dev
23−
24−- `vp run dev`: Starts contracts, server, and web in watch mode.
25−- `vp run dev --share`: Also publishes the web port over HTTPS on this machine's tailnet. The
26− startup pairing URL is built against the shared origin, and the mapping is removed on exit.
27− Shared runs default to Vite's bundled dev mode (`T3CODE_BUNDLED_DEV=1`): a remote browser pays a
28− network round trip per import level in unbundled dev, which turns a cold module graph into
29− minutes of waterfall. Set `T3CODE_BUNDLED_DEV=0` to opt a shared run back out.
30− The web entry loads the app with a dynamic import so React refresh starts before shared UI
31− chunks run. Keep app imports out of that entry. A static import can work on the first load
32− and then fail on reload after Vite splits code for lazy routes.
33− Bundled dev uses Tailwind's watched files to rebuild CSS. Its Vite-only hot-update hook is
34− disabled in this mode because Rolldown does not supply the Vite server or module graph.
35−- `vp run dev --browser`: Auto-opens a browser. Off by default. The dev runner writes
36− `T3CODE_NO_BROWSER` itself from this flag, so setting `T3CODE_NO_BROWSER=0` in your environment has
37− no effect; use `--browser`.
38−- `vp run dev:server`: Starts just the server. It runs on Node (`node --watch src/bin.ts`), so
39− without Bun present it selects `NodePtyAdapter` and `NodeHttpServer`.
40−- `vp run dev:web`: Starts just the Vite dev server for the web app.
41−- `vp run dev:desktop`: Starts the Electron shell against the dev server.
42−- `vp run dev:marketing`: Starts the Astro marketing site.
43−- Pass dev-runner flags directly after the root task name, for example:
44− `vp run dev --home-dir /tmp/t3code-dev`
45−
46−### Dev state directories
47−
48−- Dev commands run from a linked **git worktree** default to that worktree's gitignored `.t3`, even
49− when `T3CODE_HOME` is set, storing state in `<worktree>/.t3/userdata`. Pass `--home-dir <path>` to
50− choose another isolated directory explicitly. Submodules are not worktrees and keep the normal
51− precedence.
52−- From the **main checkout**, dev commands implicitly use `~/.t3/dev`, keeping development state
53− separate from `~/.t3/userdata`. An explicit `--home-dir <path>` stores state under
54− `<path>/userdata`; the base directory remains available for caches, worktrees, and other shared
55− data.
56−
57−## Build, check, test
58−
59−- `vp run build`: Fans out over `apps/*`, `packages/*`, `oxlint-plugin-t3code`, and `scripts`.
60− Workspaces that define a build task run one: desktop, marketing, server (which depends on web), and
61− web. Shared packages are consumed and bundled transitively rather than built separately.
62−- `vp run build:desktop`: Builds the desktop pipeline (desktop plus server).
63−- `vp run start`: Runs the production server (serves the built web app as static files).
64−- `vp check`: Vite+ format, lint, and type checks. This repo sets `typeCheck: false` in its lint
65− options, so workspace type checking runs separately.
66−- `vp run typecheck`: Strict TypeScript checks for all packages.
67−- `vp run test`: Runs workspace tests.
68−- `vp run lint:mobile`: Mobile native static analysis (`scripts/mobile-native-static-check.ts`).
69−- `node apps/server/scripts/t3-sqlite-state.ts <query|exec> --base-dir <path> ...`: Inspects or seeds
70− an isolated T3 SQLite database; writes create a private backup first.
71−
72−## Desktop artifacts
73−
74−- `vp run dist:desktop:artifact --platform <mac|linux|win> --target <target> --arch <arch>`: Builds a desktop artifact for a specific platform/target/arch.
75−- `vp run dist:desktop:dmg`: Builds a shareable macOS `.dmg` into `./release`. Architecture defaults
76− to the host, so this produces an arm64 DMG on Apple Silicon. Use `dist:desktop:dmg:arm64` or
77− `dist:desktop:dmg:x64`, or pass `--arch <arm64|x64|universal>`, to force one.
78−- `vp run dist:desktop:linux`: Builds a Linux AppImage into `./release`.
79−- `vp run dist:desktop:win`: Builds a Windows NSIS installer into `./release`. `:arm64` and `:x64`
80− variants exist.
81−
82−### Linux AppImage prerequisites
83−
84−Linux AppImage packaging compiles the Rust resource monitor and the libsecret browser import
85−helper. Install a Rust toolchain, the standard C/C++ build tools, libsecret development headers,
86−pkg-config, and ImageMagick before running `vp run dist:desktop:linux`.
87−
88−Ubuntu and Debian:
89−
90−```bash
91−sudo apt-get update
92−sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick
93−```
94−
95−Fedora:
96−
97−```bash
98−sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick
99−```
100−
101−Arch Linux:
102−
103−```bash
104−sudo pacman -S rust base-devel libsecret pkgconf imagemagick
105−```
106−
107−Linux desktop development also needs the C compiler, pkg-config, and libsecret headers. Its build
108−and launch commands compile `native/browser-secret/main.c` into the gitignored native build
109−directory. Releases place the executable in `resources/browser-secret`, outside `app.asar`.
110−The helper performs a read-only Chromium schema lookup through libsecret and writes the exact
111−secret bytes to its stdout pipe. Exit codes are 2 for a missing key, 3 for denied/locked access,
112−and 4 for other keyring failures; the desktop importer preserves these distinctions.
113−
114−The artifact script checks these capabilities before starting the web and desktop builds. If
115−anything is unavailable, it reports every failed check together and prints the Ubuntu/Debian
116−installation command. The check compiles and links tiny temporary programs, so it also catches
117−installed runtime X11 libraries that are missing their development headers or linker symlinks.
118−
119−### macOS DMG prerequisites
120−
121−Install the Xcode Command Line Tools and Rust before building a DMG:
122−
123−```bash
124−xcode-select --install
125−```
126−
127−Install Rust from [rustup.rs](https://rustup.rs). The artifact script checks Cargo, Clang, Make,
128−`sips`, and `iconutil`. Universal builds additionally require `lipo`. It also verifies that Rust
129−has every requested target; add missing targets with:
130−
131−```bash
132−rustup target add aarch64-apple-darwin x86_64-apple-darwin
133−```
134−
135−Unsigned local builds need no Apple credentials. Builds using `--signed` additionally require the
136−certificate, provisioning profile, team ID, and notarization configuration described below.
137−
138−### Windows installer prerequisites
139−
140−Install Rust from [rustup.rs](https://rustup.rs), Python 3, and Visual Studio Build Tools. In the
141−Visual Studio Installer, select **Desktop development with C++** and include:
142−
143−- MSVC x64/x86 build tools
144−- A Windows 10 or Windows 11 SDK
145−- MSVC Spectre-mitigated libraries
146−
147−ARM64 installers require the corresponding MSVC ARM64 build tools and Spectre-mitigated libraries
148−instead of the x64/x86 components.
149−
150−Add the Rust target matching the installer architecture:
151−
152−```powershell
153−rustup target add x86_64-pc-windows-msvc
154−# For an arm64 installer:
155−rustup target add aarch64-pc-windows-msvc
156−```
157−
158−Windows supplies `tar.exe`; it is checked when `--wsl-prebuild` makes the artifact include the WSL
159−runtime. NSIS is downloaded by electron-builder and does not need a separate installation.
160−When `T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR=true` points the build at an existing resource monitor,
161−the artifact script skips the Rust and Visual Studio checks because it does not compile the monitor.
162−Unsigned local builds need no Azure credentials. Builds using `--signed` additionally require the
163−Azure Trusted Signing configuration described below.
164−
165−### Desktop `.dmg` packaging notes
166−
167−- Default build is unsigned/not notarized for local sharing.
168−- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source.
169−- The DMG chrome follows the release channel: neutral for Latest and the Nightly sky artwork for
170− Nightly. Blueprint artwork remains exclusive to Dev builds. Packaging rasterizes the selected
171− SVG into standard and Retina PNGs inside the disposable staging directory.
172−- The Finder window is 540×412 while its background is 540×380; the extra 32px accounts for the
173− title bar included in Finder's window bounds.
174−- Desktop production windows load the bundled UI from the `t3code://app/` root URL (not a
175− `127.0.0.1` document URL, and not an explicit `index.html` path).
176−- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an
177− auth token for WebSocket/API traffic.
178−- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first
179− launch.
180−- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg --keep-stage`
181−- To allow code-signing/notarization when configured in CI/secrets, add: `--signed`.
182−- Signed macOS builds also require `T3CODE_APPLE_TEAM_ID` and
183− `T3CODE_MACOS_PROVISIONING_PROFILE`. The passkey RP domain is derived from
184− `T3CODE_CLERK_PUBLISHABLE_KEY` unless `T3CODE_CLERK_PASSKEY_RP_DOMAINS` overrides it.
185−- Windows `--signed` uses Azure Trusted Signing and expects:
186− `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`,
187− `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, and `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME`.
188−- Azure authentication env vars are also required (for example service principal with secret):
189− `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`.
190−
191−## Browser development
192−
193−`dev` and `dev:web` leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so the browser resolves the backend
194−from `window.location.origin`. Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the
195−server, allowing the same bundle to work from localhost or a tailnet hostname.
196−
197−## Running multiple dev instances
198−
199−Worktrees derive a preferred port offset from their path.
200−
201−- Default ports: server `13773`, web `5733`
202−- Shifted ports: `base + offset`
203−- Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop`
204−
205−Offset resolution, in order:
206−
207−1. `T3CODE_PORT_OFFSET`, which must be a non-negative integer. Negative values are rejected.
208−2. `T3CODE_DEV_INSTANCE`. An all-digit value is used directly as the offset; any other non-empty
209− value is hashed into one.
210−3. The worktree path hash.
211−
212−Collision scanning depends on the mode. `dev:web` scans only the web port and shifts only the web
213−offset. `dev:server` scans only the server port. `dev` and `dev:desktop` scan both and shift them
214−together as one shared offset. Explicit server or dev-URL overrides remove the corresponding port
215−from the availability check. Treat the `[dev-runner]` output as authoritative.
docs/internals/server-updates.md+57−107
@@ -1,107 +1,57 @@
1−# Server Update Architecture
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−Remote server updates use one stable launcher selected by the platform service manager (systemd on
6−Linux, launchd on macOS). Foreground CLI processes do not self-update, and a running server never
7−edits its service definition or durable service state.
8−
9−## Ownership
10−
11−The service files under `<baseDir>/runtime` are:
12−
13−- `service-launcher.mjs`, the stable process selected by the service manager;
14−- `service-state.json`, the launcher's durable selection state;
15−- `versions/<version>`, immutable exact-version npm installs.
16−
17−The launcher is the only runtime writer of `service-state.json`. `t3 service install` and
18−`t3 service update` may replace the launcher and state while the unit is stopped. Server children
19−only communicate with the launcher over their inherited IPC channel.
20−
21−The state contains one active version and, at most, one update record:
22−
23−- `pending A → B` selects B as a retryable trial;
24−- `committed A → B` selects B for ordinary restarts;
25−- `rolled-back A → B` or `failed A → B` selects A;
26−- invalid state fails closed so the service manager cannot guess at a runtime.
27−
28−Every write uses same-directory replacement plus file and directory fsync.
29−
30−## Remote Update
31−
32−1. The active server installs `t3@<target>` into a unique staging directory.
33−2. The target runs `__service-preflight` and verifies that the stable launcher supports its update
34− protocol.
35−3. The staging directory is renamed to its immutable version path only after preflight succeeds.
36−4. The active child sends `request-update`. The launcher validates the child and target, writes
37− pending state, generates the update ID, then replies `update-accepted`.
38−5. After a short response-flush grace period, the launcher stops the active child.
39−6. With SQLite quiescent, the launcher snapshots the database, WAL, and shared-memory files.
40−7. The launcher starts the target as a trial and gives it the pending update over IPC.
41−8. The trial runs migrations, acquires dependencies, binds HTTP, starts every long-running root
42− fiber, and verifies that each root is parked at the activation gate.
43−9. The trial sends `prepared`. The launcher durably commits B, deletes the snapshot, then replies
44− `committed`.
45−10. The child opens the existing activation gate, accepts commands, and publishes lifecycle ready
46− with the terminal update outcome.
47−
48−Post-commit startup does not call service `start`, `initialize`, `connect`, `load`, or `acquire`
49−operations. It only opens prepared gates and publishes prepared lifecycle state.
50−
51−The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within
52−120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the
53−snapshot, records rollback, and starts A. A durable restore marker makes an interrupted restore
54−resume before either version can boot. After commit, B is active and the service manager's normal
55−restart policy applies.
56−
57−## Database Rollback
58−
59−The launcher snapshots `state.sqlite`, `state.sqlite-wal`, and `state.sqlite-shm` after the old
60−server stops and before the trial starts. This makes trial migrations and writes reversible without
61−requiring down migrations. The snapshot is retained across launcher restarts and is removed only
62−after commit or after both restore and the terminal rollback state are durable.
63−
64−The protocol version is part of the safety boundary. A target that requires database snapshots is
65−blocked when the installed launcher is too old. Upgrade the launcher once with:
66−
67−```sh
68−npx t3@<version> service update
69−```
70−
71−The local command stops the unit, selects the new launcher and exact runtime, then restarts the
72−service. Later releases, including releases with migrations, can use the remote trial path.
73−
74−Snapshots briefly require enough free disk for another copy of the SQLite files. Attachments and
75−other files under the state directory are outside this rollback boundary.
76−
77−## Client Correlation
78−
79−The update acknowledgement includes the launcher-generated update ID. After reconnecting, clients
80−wait for a lifecycle ready event carrying that same ID. `committed` completes the operation only
81−when the ready server is the target version. `rolled-back` and `failed` end it immediately with the
82−recorded reason. Older servers without an ID retain version-only reconnect behavior.
83−
84−## Capability and Compatibility
85−
86−The existing additive RPC and lifecycle schemas remain compatible with older clients. New servers
87−advertise remote self-update only when they have valid launcher context and a live IPC channel.
88−Desktop-managed servers advertise `desktopAppUpdate` when the desktop telemetry control fd is
89−attached. The progress RPC asks the desktop app to check and download, then returns a preparation
90−token while the backend is still connected. Only after the client receives that result does it send
91−`server.commitDesktopUpdate`. A successful commit closes the connection and must reconnect at the
92−prepared version. The desktop app and bundled server versions stay equal because
93−`scripts/update-release-package-versions.ts` bumps them together. If install fails, the desktop keeps its windows, restarts stopped backends, and
94−replays the failure for the same token. This two-phase handoff prevents backend shutdown from
95−dropping the only successful RPC result. Desktop servers without the capability direct the user to
96−update the desktop app locally. Other process shapes provide a manual command; the old detached
97−foreground respawn path no longer exists.
98−
99−## Source Map
100−
101−- Launcher and state machine: `apps/server/src/serviceLauncher.ts`
102−- IPC and durable state types: `apps/server/src/cloud/serviceProtocol.ts`
103−- Child IPC adapter: `apps/server/src/cloud/serviceLauncherClient.ts`
104−- Staging and preflight: `apps/server/src/cloud/pinnedRuntime.ts` and `servicePreflight.ts`
105−- Service installation: `apps/server/src/cloud/bootService.ts`
106−- Activation boundary: `apps/server/src/serverRuntimeStartup.ts` and `serverActivation.ts`
107−- Client outcome correlation: `packages/client-runtime/src/state/server.ts`
1+# Server updates
2+
3+A [stable launcher](../../apps/server/src/serviceLauncher.ts) owns the runtime
4+selected by systemd or launchd. It is the only runtime writer of durable service
5+state. Server children request updates over inherited IPC; they never rewrite
6+their service definition or select their own replacement. Local service commands
7+may replace the launcher and state while the service is stopped. Foreground CLI
8+processes do not self-update.
9+
10+Exact-version installs keep restarts independent of npm cache eviction or a moving
11+release tag. Installation and preflight happen in staging before publishing an
12+immutable runtime. Preflight checks the launcher protocol because a target that
13+needs new rollback guarantees cannot safely run under an older launcher. Upgrading
14+that launcher requires a local service update.
15+
16+## Commit boundary
17+
18+The launcher durably records the pending update before acknowledging it, then
19+stops the old child and starts the target as a trial. Service-state writes use
20+same-directory replacement with file and directory fsync. Invalid state stops
21+startup rather than guessing which runtime to boot.
22+
23+The trial must finish migrations, acquire dependencies, bind HTTP, and park every
24+long-running root at the activation gate before reporting `prepared`. The launcher
25+then commits the target version durably and replies `committed`. Only then may the
26+child release its gates, accept commands, and publish ready. Keep fallible startup
27+acquisitions before this boundary. A listener alone does not prove the runtime is
28+ready to commit.
29+
30+A failed or timed-out trial returns to the old version. After commit, the target
31+is authoritative and the service manager's ordinary restart policy applies.
32+
33+## Database rollback
34+
35+After the old child exits, the launcher snapshots SQLite's main file, WAL, and
36+shared-memory file. This makes trial migrations reversible without down
37+migrations. The snapshot is made once per update and survives launcher restarts;
38+replacing it during a retry could capture changes from the failed trial.
39+
40+Rollback stops the trial before restoring. A durable restore marker makes an
41+interrupted restore finish before either version boots. Keep the snapshot until
42+commit, or until both restoration and the terminal rollback state are durable.
43+Attachments and other files outside SQLite are outside this rollback boundary.
44+
45+## Client acknowledgement
46+
47+An accepted update is still pending. Clients correlate the launcher's update ID
48+with the ready event after reconnecting, then check the outcome and target version.
49+A reconnect alone cannot distinguish successful replacement from rollback. Older
50+servers without an update ID retain version-only correlation.
51+
52+Desktop updates have a separate two-phase handoff because installing the app stops
53+its bundled backend. Preparation returns a token while the connection is alive;
54+the client commits that token only after receiving it. Otherwise backend shutdown
55+could lose the only successful RPC result. The client must then observe the
56+prepared version after reconnecting. If installation fails, desktop restarts the
57+stopped backends and replays the failure for the same token.
docs/internals/t3-code-connect-auth-flow.html+0−1160
@@ -1,1160 +0,0 @@
1−<!doctype html>
2−<html lang="en">
3− <head>
4− <meta charset="utf-8" />
5− <meta name="viewport" content="width=device-width, initial-scale=1" />
6− <title>T3 Connect Architecture</title>
7− <style>
8− :root {
9− color-scheme: light;
10− --ink: #171717;
11− --muted: #62646b;
12− --hairline: #d8d8dd;
13− --panel: #ffffff;
14− --paper: #f5f5f2;
15− --blue: #1457d9;
16− --green: #1d7d4d;
17− --amber: #a65f00;
18− --red: #b8242c;
19− --violet: #6150b8;
20− --shadow: 0 18px 55px rgba(25, 25, 28, 0.08);
21− }
22−
23− * {
24− box-sizing: border-box;
25− }
26−
27− body {
28− margin: 0;
29− background:
30− linear-gradient(90deg, rgba(0, 0, 0, 0.035) 1px, transparent 1px),
31− linear-gradient(rgba(0, 0, 0, 0.035) 1px, transparent 1px), var(--paper);
32− background-size: 34px 34px;
33− color: var(--ink);
34− font-family:
35− ui-sans-serif,
36− -apple-system,
37− BlinkMacSystemFont,
38− "Segoe UI",
39− sans-serif;
40− line-height: 1.48;
41− }
42−
43− .shell {
44− width: min(1540px, calc(100vw - 40px));
45− margin: 0 auto;
46− padding: 38px 0 72px;
47− }
48−
49− header {
50− border-bottom: 2px solid var(--ink);
51− padding-bottom: 24px;
52− margin-bottom: 28px;
53− }
54−
55− .eyebrow {
56− color: var(--blue);
57− font-size: 12px;
58− font-weight: 800;
59− letter-spacing: 0.08em;
60− text-transform: uppercase;
61− }
62−
63− h1 {
64− max-width: 980px;
65− margin: 8px 0 12px;
66− font-size: clamp(34px, 5vw, 66px);
67− line-height: 0.98;
68− }
69−
70− h2 {
71− margin: 0 0 14px;
72− font-size: 26px;
73− line-height: 1.08;
74− }
75−
76− h3 {
77− margin: 22px 0 8px;
78− font-size: 17px;
79− }
80−
81− p {
82− margin: 0 0 12px;
83− }
84−
85− code,
86− .mono {
87− font-family:
88− ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
89− }
90−
91− code {
92− border-radius: 4px;
93− background: #f0f0ed;
94− padding: 1px 4px;
95− font-size: 0.92em;
96− }
97−
98− .lede {
99− max-width: 1000px;
100− margin: 0;
101− color: var(--muted);
102− font-size: 18px;
103− }
104−
105− .grid {
106− display: grid;
107− grid-template-columns: repeat(12, 1fr);
108− gap: 18px;
109− }
110−
111− .panel {
112− grid-column: span 12;
113− border: 1px solid var(--hairline);
114− border-radius: 10px;
115− background: var(--panel);
116− box-shadow: var(--shadow);
117− padding: 22px;
118− }
119−
120− .half {
121− grid-column: span 6;
122− }
123−
124− .callout {
125− border-left: 5px solid var(--blue);
126− background: #eef4ff;
127− padding: 14px 16px;
128− margin-top: 14px;
129− }
130−
131− .callout.warning {
132− border-left-color: var(--amber);
133− background: #fff6e6;
134− }
135−
136− .badge-row {
137− display: flex;
138− flex-wrap: wrap;
139− gap: 8px;
140− margin-top: 14px;
141− }
142−
143− .badge {
144− border: 1px solid var(--hairline);
145− border-radius: 999px;
146− background: #fff;
147− padding: 5px 9px;
148− font-size: 12px;
149− font-weight: 700;
150− }
151−
152− .badge.user {
153− border-color: rgba(20, 87, 217, 0.3);
154− color: var(--blue);
155− }
156−
157− .badge.env {
158− border-color: rgba(29, 125, 77, 0.3);
159− color: var(--green);
160− }
161−
162− .badge.local {
163− border-color: rgba(166, 95, 0, 0.35);
164− color: var(--amber);
165− }
166−
167− .diagram {
168− overflow-x: auto;
169− margin: 12px 0 0;
170− border: 1px solid var(--hairline);
171− border-radius: 8px;
172− background: #fbfbf8;
173− color: var(--ink);
174− padding: 18px;
175− }
176−
177− .diagram svg {
178− display: block;
179− width: auto !important;
180− min-width: 100%;
181− max-width: none !important;
182− height: auto;
183− margin: 0 auto;
184− }
185−
186− table {
187− width: 100%;
188− border-collapse: collapse;
189− font-size: 14px;
190− }
191−
192− th,
193− td {
194− border-bottom: 1px solid var(--hairline);
195− padding: 11px 9px;
196− text-align: left;
197− vertical-align: top;
198− }
199−
200− th {
201− color: var(--muted);
202− font-size: 12px;
203− letter-spacing: 0.05em;
204− text-transform: uppercase;
205− }
206−
207− ul {
208− margin: 8px 0 0;
209− padding-left: 20px;
210− }
211−
212− li + li {
213− margin-top: 6px;
214− }
215−
216− .flow {
217− display: grid;
218− gap: 10px;
219− margin-top: 12px;
220− }
221−
222− .step {
223− border: 1px solid var(--hairline);
224− border-left: 5px solid var(--green);
225− border-radius: 8px;
226− background: #fff;
227− padding: 12px 14px;
228− }
229−
230− .step strong {
231− display: block;
232− margin-bottom: 3px;
233− }
234−
235− .source-list a {
236− color: var(--blue);
237− font-weight: 700;
238− }
239−
240− @media (max-width: 860px) {
241− .half {
242− grid-column: span 12;
243− }
244−
245− .shell {
246− width: min(100vw - 24px, 1540px);
247− padding-top: 24px;
248− }
249− }
250− </style>
251− </head>
252− <body>
253− <main class="shell">
254− <header>
255− <div class="eyebrow">Implemented architecture</div>
256− <h1>T3 Connect Control Plane and Managed Endpoint Flow</h1>
257− <p class="lede">
258− T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions
259− a managed HTTPS/WSS endpoint for that environment, and brokers proof-bound remote
260− connections. The local environment remains the authority that issues environment access
261− credentials. The hosted relay stores links, reconciles managed endpoint resources,
262− validates signed proofs, and delivers agent-activity notifications.
263− </p>
264− <div class="badge-row">
265− <span class="badge user">Client -> Relay: Clerk bearer or relay DPoP</span>
266− <span class="badge env">Relay -> Environment: relay-signed health and mint proofs</span>
267− <span class="badge env"
268− >Environment -> Relay: environment bearer plus signed activity proof</span
269− >
270− <span class="badge local">Client -> Environment: environment access token plus DPoP</span>
271− </div>
272− </header>
273−
274− <section class="grid" aria-label="Architecture summary">
275− <article class="panel half">
276− <h2>Security Invariant</h2>
277− <p>
278− A cloud identity is not an environment login. Remote access requires all of the
279− following:
280− </p>
281− <ul>
282− <li>a Clerk-authenticated T3 Connect user;</li>
283− <li>an active relay link for that user and environment;</li>
284− <li>a DPoP proof key held by the remote client;</li>
285− <li>a relay-signed mint request accepted by the linked local environment; and</li>
286− <li>an environment-issued credential bound to the remote client's DPoP thumbprint.</li>
287− </ul>
288− <div class="callout">
289− The relay cannot mint an environment access token by itself. It can only ask the local
290− environment to mint a short-lived bootstrap credential for an authorized cloud user.
291− </div>
292− <p>During an honest connection flow, the relay cannot act as the remote client:</p>
293− <ul>
294− <li>
295− Normal environment API traffic goes directly from the remote client through the
296− managed endpoint. The relay does not receive the resulting environment access token.
297− </li>
298− <li>
299− The one-time bootstrap credential and resulting environment access token are bound to
300− the remote client's DPoP public-key thumbprint. Redeeming or using them requires
301− request proofs signed by the corresponding private key, which stays on the client.
302− </li>
303− <li>
304− Relay-signed health and mint proofs are short-lived, audience-bound, scoped, and
305− replay-guarded. They are accepted only by the narrow cloud endpoints, not as normal
306− environment API credentials.
307− </li>
308− <li>
309− Environment health and mint responses are signed by the linked environment and bound
310− to the request nonce, so a different process behind the tunnel cannot impersonate the
311− linked environment.
312− </li>
313− </ul>
314− <div class="callout warning">
315− The hosted relay remains a trusted mint broker: it holds the cloud-mint signing key.
316− DPoP prevents credential reuse by the relay or tunnel transport during an honest flow,
317− but it does not make compromise of the relay signing authority harmless.
318− </div>
319− </article>
320−
321− <article class="panel half">
322− <h2>Product Boundaries</h2>
323− <p>
324− The generic local connector is called the <strong>relay client</strong>. The current
325− implementation layer uses <code>cloudflared</code>, but that is an internal transport
326− detail rather than the product contract.
327− </p>
328− <ul>
329− <li>
330− The relay Worker owns cloud links, managed endpoint allocations, and notifications.
331− </li>
332− <li>
333− The environment server owns local sessions, environment keys, and access tokens.
334− </li>
335− <li>The relay client exposes only the environment's loopback HTTP server.</li>
336− <li>
337− Web and mobile clients connect directly to the managed environment endpoint after
338− bootstrap.
339− </li>
340− </ul>
341− </article>
342− </section>
343−
344− <section class="panel" aria-label="Topology">
345− <h2>Current Topology</h2>
346− <pre class="diagram mermaid">
347−flowchart LR
348− subgraph Clients["User-facing clients"]
349− Web["Desktop / hosted web UI"]
350− Mobile["Mobile app"]
351− CLI["Headless CLI"]
352− end
353−
354− Clerk["Clerk<br/>user identity and OAuth"]
355−
356− subgraph Relay["T3 Code Relay on Cloudflare"]
357− Worker["Relay Worker<br/>HTTP API"]
358− Queue["APNs delivery queue<br/>with dead-letter queue"]
359− Hyperdrive["Hyperdrive"]
360− CF["Cloudflare tunnel + DNS APIs"]
361− end
362−
363− DB["PlanetScale Postgres<br/>prod database + stage branches"]
364− APNs["Apple Push Notification service"]
365− Traces["Axiom OTLP traces"]
366−
367− subgraph Local["Linked T3 environment"]
368− Env["Environment server<br/>local auth authority"]
369− RelayClient["Relay client<br/>cloudflared implementation"]
370− Secrets["Server secret store<br/>keys + relay config"]
371− end
372−
373− Endpoint["Managed HTTPS/WSS endpoint"]
374−
375− Web -->|"Clerk bearer / relay DPoP"| Worker
376− Mobile -->|"Clerk bearer / relay DPoP"| Worker
377− CLI -->|"Clerk OAuth bearer"| Worker
378− Web --> Clerk
379− Mobile --> Clerk
380− CLI -->|"PKCE authorization code flow"| Clerk
381− Worker --> Hyperdrive --> DB
382− Worker --> Traces
383− Worker --> CF
384− Worker --> Queue --> APNs
385− Worker --> Endpoint
386− Endpoint --> RelayClient -->|"http://127.0.0.1:port"| Env
387− Env --> Secrets
388− Web -->|"environment token + DPoP<br/>HTTPS / WSS"| Endpoint
389− Mobile -->|"environment token + DPoP<br/>HTTPS / WSS"| Endpoint
390− Env -->|"environment bearer + signed activity proof"| Worker
391− </pre>
392− </section>
393−
394− <section class="panel" aria-label="Authentication matrix">
395− <h2>Authentication and Transport Matrix</h2>
396− <table>
397− <thead>
398− <tr>
399− <th>Boundary</th>
400− <th>Transport</th>
401− <th>Authentication</th>
402− <th>Purpose</th>
403− </tr>
404− </thead>
405− <tbody>
406− <tr>
407− <td>Web or mobile -> relay link management</td>
408− <td>HTTPS</td>
409− <td>Clerk session-template bearer token</td>
410− <td>Create a link challenge, submit an environment proof, list links, or unlink.</td>
411− </tr>
412− <tr>
413− <td>CLI -> relay link management</td>
414− <td>HTTPS</td>
415− <td>Clerk OAuth access token obtained with PKCE</td>
416− <td>Reconcile the desired headless cloud link when the server starts.</td>
417− </tr>
418− <tr>
419− <td>Web or mobile -> relay protected client endpoints</td>
420− <td>HTTPS</td>
421− <td>Relay-issued DPoP token plus per-request DPoP proof</td>
422− <td>
423− Check environment status, request a remote connection, and register mobile devices.
424− </td>
425− </tr>
426− <tr>
427− <td>Relay -> managed environment endpoint</td>
428− <td>HTTPS over the managed tunnel</td>
429− <td>Short-lived relay-signed JWT request proof</td>
430− <td>
431− Request a signed health response or ask the local environment to mint a credential.
432− </td>
433− </tr>
434− <tr>
435− <td>Environment -> relay activity publication</td>
436− <td>HTTPS</td>
437− <td>Relay-issued environment bearer credential plus environment-signed JWT proof</td>
438− <td>
439− Publish redacted agent-activity state for push notifications and Live Activities.
440− </td>
441− </tr>
442− <tr>
443− <td>Remote client -> environment</td>
444− <td>HTTPS and WSS over the managed tunnel</td>
445− <td>Environment-issued access token bound to the client DPoP key</td>
446− <td>Use the normal environment HTTP APIs and request a one-time WebSocket ticket.</td>
447− </tr>
448− </tbody>
449− </table>
450− </section>
451−
452− <section class="panel" aria-label="Credential ownership">
453− <h2>Credential Ownership</h2>
454− <table>
455− <thead>
456− <tr>
457− <th>Credential</th>
458− <th>Created by</th>
459− <th>Stored by</th>
460− <th>Use</th>
461− </tr>
462− </thead>
463− <tbody>
464− <tr>
465− <td>Clerk session-template JWT</td>
466− <td>Clerk</td>
467− <td>Web or mobile client</td>
468− <td>
469− Authenticate cloud-user relay requests and bootstrap relay DPoP token exchange.
470− </td>
471− </tr>
472− <tr>
473− <td>CLI OAuth access and refresh token</td>
474− <td>Clerk OAuth</td>
475− <td>Local server secret store</td>
476− <td>Authorize headless CLI link reconciliation.</td>
477− </tr>
478− <tr>
479− <td>Environment Ed25519 key pair</td>
480− <td>Local environment server</td>
481− <td>Local server secret store</td>
482− <td>
483− Sign link proofs, health responses, mint responses, and activity publication proofs.
484− </td>
485− </tr>
486− <tr>
487− <td>Relay cloud-mint key pair</td>
488− <td>Relay deployment</td>
489− <td>Private key in relay Worker config; public key installed into the environment</td>
490− <td>Sign relay-to-environment health and mint requests.</td>
491− </tr>
492− <tr>
493− <td>Relay environment bearer credential</td>
494− <td>Relay</td>
495− <td>Hashed in relay Postgres; plaintext in local server secret store</td>
496− <td>Authenticate environment-to-relay agent-activity publication.</td>
497− </tr>
498− <tr>
499− <td>Relay DPoP access token</td>
500− <td>Relay</td>
501− <td>Remote client memory cache</td>
502− <td>
503− Authorize status, connect, and mobile registration endpoints with proof of
504− possession.
505− </td>
506− </tr>
507− <tr>
508− <td>Environment bootstrap credential</td>
509− <td>Local environment server</td>
510− <td>Passed relay -> remote client</td>
511− <td>One-time exchange at the environment <code>/oauth/token</code> endpoint.</td>
512− </tr>
513− <tr>
514− <td>Environment access token</td>
515− <td>Local environment server</td>
516− <td>Remote client</td>
517− <td>Authorize environment HTTP operations and WebSocket ticket issuance.</td>
518− </tr>
519− <tr>
520− <td>WebSocket ticket</td>
521− <td>Local environment server</td>
522− <td>Remote client until consumed</td>
523− <td>Short-lived one-time credential for the WSS upgrade.</td>
524− </tr>
525− <tr>
526− <td>Relay client connector token</td>
527− <td>Cloudflare tunnel API</td>
528− <td>Local server secret store</td>
529− <td>
530− Start the local relay client. It is passed through <code>TUNNEL_TOKEN</code>, not a
531− shell argument.
532− </td>
533− </tr>
534− </tbody>
535− </table>
536− </section>
537−
538− <section class="grid" aria-label="Core flows">
539− <article class="panel">
540− <h2>Flow 1: Link From the Desktop Web UI</h2>
541− <pre class="diagram mermaid">
542−sequenceDiagram
543− participant U as Signed-in user
544− participant C as Desktop web UI
545− participant E as Local environment
546− participant R as Relay Worker
547− participant CF as Cloudflare APIs
548− participant RC as Local relay client
549−
550− U->>C: Enable T3 Connect for a locally paired environment
551− C->>E: Check relay client availability
552− alt relay client is missing
553− C->>U: Prompt before download and install
554− U->>C: Confirm
555− C->>E: Stream relay-client install RPC
556− E-->>C: Progress stages
557− end
558− C->>R: POST /v1/client/environment-link-challenges (Clerk bearer)
559− R-->>C: Short-lived challenge
560− C->>E: POST /api/connect/link-proof (local relay:write bearer)
561− E->>E: Authenticate, reject forwarded authority, get or create key pair, validate loopback origin
562− E-->>C: Environment-signed link proof
563− C->>R: POST /v1/client/environment-links (Clerk bearer + proof)
564− R->>R: Verify challenge, proof, capabilities, nonce, and loopback origin
565− R->>CF: Reconcile tunnel, ingress, and CNAME
566− R->>R: Upsert user/environment link and issue environment bearer
567− R-->>C: Managed endpoint, relay config, and connector token
568− C->>E: POST /api/connect/relay-config (local relay:write bearer)
569− E->>RC: Start relay client with TUNNEL_TOKEN
570− </pre>
571− <div class="callout">
572− The local link-proof endpoint requires <code>relay:write</code>, rejects forwarded
573− authority headers, and accepts only an exact loopback origin. Environment keypair
574− persistence is atomic across concurrent link attempts.
575− </div>
576− <p>
577− A locally paired mobile client uses the same relay challenge, local proof, relay link,
578− and local relay-config endpoints. The desktop web UI is the surface that checks relay
579− client availability and offers the managed install dialog.
580− </p>
581− </article>
582−
583− <article class="panel">
584− <h2>Flow 2: Headless CLI Link</h2>
585− <pre class="diagram mermaid">
586−sequenceDiagram
587− participant U as Operator
588− participant CLI as t3 connect CLI
589− participant Clerk as Clerk OAuth
590− participant S as Environment server on next start
591− participant R as Relay Worker
592− participant RC as Local relay client
593−
594− U->>CLI: t3 connect link
595− CLI->>CLI: Resolve relay client
596− alt relay client is missing
597− CLI->>U: Confirm managed relay-client install
598− U->>CLI: Confirm
599− CLI->>CLI: Install with terminal progress updates
600− end
601− CLI->>Clerk: Browser PKCE authorization-code flow
602− Clerk-->>CLI: OAuth access and refresh tokens
603− CLI->>CLI: Persist desired cloud-link state
604− U->>S: Start T3 environment server
605− S->>R: Create challenge and submit local signed proof
606− R-->>S: Managed endpoint config and connector token
607− S->>RC: Start relay client
608− </pre>
609− <p>
610− <code>t3 connect login</code> stores authorization without enabling exposure.
611− <code>t3 connect unlink</code> disables exposure while retaining authorization.
612− <code>t3 connect logout</code> also removes the stored CLI authorization.
613− </p>
614− </article>
615−
616− <article class="panel">
617− <h2>Flow 3: Remote Connect Bootstrap</h2>
618− <pre class="diagram mermaid">
619−sequenceDiagram
620− participant C as Web or mobile client
621− participant Clerk as Clerk
622− participant R as Relay Worker
623− participant E as Managed environment endpoint
624−
625− C->>Clerk: Get Clerk session-template JWT
626− C->>R: POST /v1/client/dpop-token (Clerk JWT + DPoP proof)
627− R-->>C: Relay DPoP access token
628− C->>R: POST /v1/environments/:id/connect (relay DPoP)
629− R->>E: POST /api/t3-connect/mint-credential (relay-signed proof)
630− E->>E: Verify relay signer, linked user, scope, lifetime, cnf, and replay guards
631− E-->>R: Bootstrap credential + environment-signed proof
632− R->>R: Verify environment signature, nonce, endpoint, and DPoP binding
633− R-->>C: Managed endpoint + bootstrap credential
634− C->>E: POST /oauth/token (bootstrap credential + DPoP proof)
635− E-->>C: Environment DPoP-bound access token
636− C->>E: POST /api/auth/websocket-ticket
637− E-->>C: One-time WebSocket ticket
638− C->>E: WSS /ws with ticket
639− </pre>
640− <div class="callout">
641− Relay-to-environment control requests disable redirects, use only a reconciled managed
642− endpoint allocation, and require a signed environment response before the relay returns
643− data to the client.
644− </div>
645− </article>
646−
647− <article class="panel">
648− <h2>Flow 4: Agent Activity Notifications</h2>
649− <pre class="diagram mermaid">
650−sequenceDiagram
651− participant E as Local environment
652− participant R as Relay Worker
653− participant DB as Relay Postgres
654− participant Q as APNs queue
655− participant APNs as Apple Push Notification service
656− participant M as Mobile app
657−
658− E->>E: Project publishable thread state and redact failure detail
659− E->>R: POST /v1/environments/:environmentId/threads/:threadId/agent-activity
660− Note over E,R: Environment bearer credential + environment-signed per-state proof
661− R->>R: Verify credential, signature, scope, expiry, and nonce
662− R->>DB: Store current activity row
663− R->>Q: Enqueue push or Live Activity delivery jobs
664− Q->>APNs: Deliver signed APNs request
665− APNs-->>M: Push notification or Live Activity update
666− </pre>
667− <p>
668− The local server publishes a deliberately narrow projection. Failed runs use a fixed
669− redacted summary, and detail strings are capped before publication.
670− </p>
671− </article>
672− </section>
673−
674− <section class="grid" aria-label="Managed endpoint lifecycle">
675− <article class="panel half">
676− <h2>Managed Endpoint Reconciliation</h2>
677− <p>
678− Managed endpoint resources are keyed by <code>(userId, environmentId)</code>. The relay
679− reserves a stable hostname and tunnel name before provisioning, then checkpoints the
680− tunnel ID, DNS record ID, and ready state in Postgres.
681− </p>
682− <div class="flow">
683− <div class="step">
684− <strong>1. Reserve deterministic allocation</strong>
685− Hostname and tunnel name derive from relay stage, cloud user, and environment ID.
686− </div>
687− <div class="step">
688− <strong>2. Reuse or create tunnel</strong>
689− Existing named tunnels are reused. Tunnel ingress is rewritten to the validated
690− loopback origin.
691− </div>
692− <div class="step">
693− <strong>3. Reconcile DNS</strong>
694− Existing CNAMEs are updated and duplicate records are removed. Create conflicts are
695− recovered by listing and reconciling the winning record.
696− </div>
697− <div class="step">
698− <strong>4. Mark ready</strong>
699− Connect and status requests use only ready allocations whose hostname still matches
700− the relay-managed namespace.
701− </div>
702− </div>
703− </article>
704−
705− <article class="panel half">
706− <h2>Unlink Cleanup</h2>
707− <p>
708− Relay unlink removes the user's managed endpoint allocation before revoking the
709− user/environment link:
710− </p>
711− <ul>
712− <li>delete the managed DNS record if present;</li>
713− <li>delete the Cloudflare tunnel if present;</li>
714− <li>remove the allocation row;</li>
715− <li>revoke the user's environment link; and</li>
716− <li>
717− revoke the environment publication credential only after the final active link for
718− that environment key disappears.
719− </li>
720− </ul>
721− <p>
722− Local unlink stops the relay client and removes the locally persisted relay URL, issuer,
723− linked user, environment publication credential, cloud-mint public key, connector
724− config, and agent-activity preference.
725− </p>
726− </article>
727− </section>
728−
729− <section class="panel" aria-label="Cloudflare tunnel operational profile">
730− <h2>Cloudflare Tunnel Operational Profile</h2>
731− <p>
732− Managed tunnels are private application backends, not general-purpose public hosting. The
733− relay provisions one named Cloudflare tunnel and one DNS record per active
734− <code>(userId, environmentId)</code> allocation.
735− </p>
736− <ul>
737− <li>
738− <strong>Lifecycle:</strong> the tunnel is created when an environment is linked, reused
739− across retry-safe reconciliation, kept for the lifetime of that link, and deleted on
740− unlink. If teardown fails, the allocation checkpoint remains so cleanup can be retried.
741− </li>
742− <li>
743− <strong>Origin:</strong> tunnel ingress is restricted to the linked environment server's
744− validated loopback HTTP origin. Arbitrary upstream hosts and raw TCP origins are
745− rejected.
746− </li>
747− <li>
748− <strong>Protocols:</strong> managed endpoints carry HTTPS request/response traffic and
749− WSS connections only. Remote clients use the normal environment HTTP APIs and
750− authenticated WebSocket RPC transport through the tunnel.
751− </li>
752− <li>
753− <strong>Relay-generated traffic:</strong> the hosted relay sends sparse HTTPS health
754− checks and credential-mint requests during status checks and remote connection
755− bootstrap. It does not proxy steady-state remote sessions.
756− </li>
757− <li>
758− <strong>Traffic shape:</strong> expected load is interactive and user-driven. Active
759− remote sessions may keep WebSockets open and exchange terminal, agent, and UI updates;
760− inactive linked environments should carry little or no tunnel traffic.
761− </li>
762− <li>
763− <strong>Capacity planning:</strong> the project is pre-launch, so there is not yet a
764− production bandwidth baseline. Tunnel count grows with active linked user/environment
765− allocations, while bandwidth and concurrent WebSockets grow with active remote use.
766− </li>
767− </ul>
768− </section>
769−
770− <section class="panel" aria-label="SSRF and tunnel hardening">
771− <h2>SSRF and Tunnel Hardening</h2>
772− <table>
773− <thead>
774− <tr>
775− <th>Risk</th>
776− <th>Implemented control</th>
777− </tr>
778− </thead>
779− <tbody>
780− <tr>
781− <td>A client asks the relay to tunnel an arbitrary host.</td>
782− <td>
783− The environment and relay both accept managed tunnel origins only for loopback hosts
784− and valid ports.
785− </td>
786− </tr>
787− <tr>
788− <td>A forwarded request tricks local origin validation.</td>
789− <td>
790− The local link-proof handler requires an exact loopback request URL and rejects
791− forwarded host or protocol headers.
792− </td>
793− </tr>
794− <tr>
795− <td>A stored endpoint is replaced with an arbitrary external URL.</td>
796− <td>
797− Connect and status resolve a ready allocation from Postgres and require a hostname
798− under the configured managed endpoint zone.
799− </td>
800− </tr>
801− <tr>
802− <td>A managed endpoint redirects relay egress elsewhere.</td>
803− <td>
804− Relay-to-environment status and mint requests set redirect handling to
805− <code>manual</code>.
806− </td>
807− </tr>
808− <tr>
809− <td>A process behind the tunnel returns attacker-controlled data.</td>
810− <td>
811− The relay verifies environment-signed health and mint responses against the linked
812− environment public key, expected nonce, and request binding.
813− </td>
814− </tr>
815− <tr>
816− <td>A stolen remote access token is replayed.</td>
817− <td>
818− Relay and environment access tokens are DPoP-bound; per-request proofs and replay
819− guards are required.
820− </td>
821− </tr>
822− <tr>
823− <td>The relay client token leaks through process listings or shell parsing.</td>
824− <td>
825− The relay client is spawned without a shell and receives its connector token in
826− <code>TUNNEL_TOKEN</code>.
827− </td>
828− </tr>
829− </tbody>
830− </table>
831− <div class="callout warning">
832− Managed tunnel hostnames live under the configured tunnel DNS zone. Serving them from a
833− dedicated registrable domain with a Public Suffix List entry remains an operational
834− isolation requirement before broad untrusted use.
835− </div>
836− </section>
837−
838− <section class="panel" aria-label="Relay HTTP API">
839− <h2>Relay HTTP API</h2>
840− <p>
841− The relay publishes an OpenAPI document at <code>/openapi.json</code>, interactive API
842− docs at <code>/docs</code>, and redirects <code>/</code> to the docs.
843− </p>
844− <table>
845− <thead>
846− <tr>
847− <th>Endpoint</th>
848− <th>Authentication</th>
849− <th>Purpose</th>
850− </tr>
851− </thead>
852− <tbody>
853− <tr>
854− <td><code>GET /health</code></td>
855− <td>None</td>
856− <td>Relay health check.</td>
857− </tr>
858− <tr>
859− <td><code>GET /.well-known/oauth-authorization-server</code></td>
860− <td>None</td>
861− <td>Relay OAuth token-exchange discovery metadata.</td>
862− </tr>
863− <tr>
864− <td><code>GET /.well-known/oauth-protected-resource</code></td>
865− <td>None</td>
866− <td>Relay DPoP protected-resource discovery metadata.</td>
867− </tr>
868− <tr>
869− <td><code>GET /v1/environments</code></td>
870− <td>Clerk bearer</td>
871− <td>List environments linked to the signed-in user.</td>
872− </tr>
873− <tr>
874− <td><code>POST /v1/client/environment-link-challenges</code></td>
875− <td>Clerk bearer</td>
876− <td>Create a short-lived user-bound environment link challenge.</td>
877− </tr>
878− <tr>
879− <td><code>POST /v1/client/environment-links</code></td>
880− <td>Clerk bearer</td>
881− <td>
882− Verify a local environment proof, reconcile a managed endpoint, and upsert the user
883− link.
884− </td>
885− </tr>
886− <tr>
887− <td><code>DELETE /v1/client/environment-links/:environmentId</code></td>
888− <td>Clerk bearer</td>
889− <td>Remove managed endpoint resources and revoke the user's link.</td>
890− </tr>
891− <tr>
892− <td><code>POST /v1/client/dpop-token</code></td>
893− <td>Clerk token in token-exchange payload plus DPoP proof</td>
894− <td>Issue a relay DPoP access token for the requested supported scopes.</td>
895− </tr>
896− <tr>
897− <td><code>POST /v1/environments/:environmentId/status</code></td>
898− <td>Relay DPoP</td>
899− <td>Request and validate a signed environment health response.</td>
900− </tr>
901− <tr>
902− <td><code>POST /v1/environments/:environmentId/connect</code></td>
903− <td>Relay DPoP</td>
904− <td>Request and validate an environment bootstrap credential.</td>
905− </tr>
906− <tr>
907− <td><code>POST /v1/mobile/devices</code></td>
908− <td>Relay DPoP</td>
909− <td>Register or update a mobile device for notifications.</td>
910− </tr>
911− <tr>
912− <td><code>POST /v1/mobile/live-activities</code></td>
913− <td>Relay DPoP</td>
914− <td>Register a Live Activity push token.</td>
915− </tr>
916− <tr>
917− <td><code>DELETE /v1/mobile/devices/:deviceId</code></td>
918− <td>Relay DPoP</td>
919− <td>Unregister a mobile device.</td>
920− </tr>
921− <tr>
922− <td>
923− <code>POST /v1/environments/:environmentId/threads/:threadId/agent-activity</code>
924− </td>
925− <td>Environment bearer plus signed publish proof</td>
926− <td>Publish redacted current agent activity for notification delivery.</td>
927− </tr>
928− </tbody>
929− </table>
930− </section>
931−
932− <section class="panel" aria-label="Environment HTTP API">
933− <h2>Environment Cloud HTTP API</h2>
934− <table>
935− <thead>
936− <tr>
937− <th>Endpoint</th>
938− <th>Authentication</th>
939− <th>Purpose</th>
940− </tr>
941− </thead>
942− <tbody>
943− <tr>
944− <td><code>POST /api/connect/link-proof</code></td>
945− <td>Local environment token with <code>relay:write</code></td>
946− <td>Validate local origin and sign an environment link proof.</td>
947− </tr>
948− <tr>
949− <td><code>POST /api/connect/relay-config</code></td>
950− <td>Local environment token with <code>relay:write</code></td>
951− <td>Persist linked relay configuration and start the relay client.</td>
952− </tr>
953− <tr>
954− <td><code>GET /api/connect/link-state</code></td>
955− <td>Local environment token with <code>relay:read</code></td>
956− <td>Read the environment's current T3 Connect link state.</td>
957− </tr>
958− <tr>
959− <td><code>POST /api/connect/preferences</code></td>
960− <td>Local environment token with <code>relay:write</code></td>
961− <td>Enable or disable agent-activity publication.</td>
962− </tr>
963− <tr>
964− <td><code>POST /api/connect/unlink</code></td>
965− <td>Local environment token with <code>relay:write</code></td>
966− <td>Stop the relay client and clear local T3 Connect configuration.</td>
967− </tr>
968− <tr>
969− <td><code>POST /api/t3-connect/health</code></td>
970− <td>Relay-signed proof</td>
971− <td>Return a signed environment health response.</td>
972− </tr>
973− <tr>
974− <td><code>POST /api/t3-connect/mint-credential</code></td>
975− <td>Relay-signed proof</td>
976− <td>Mint a DPoP-bound one-time bootstrap credential for a remote client.</td>
977− </tr>
978− <tr>
979− <td><code>POST /oauth/token</code></td>
980− <td>Bootstrap credential plus DPoP proof</td>
981− <td>Exchange a bootstrap credential for an environment access token.</td>
982− </tr>
983− <tr>
984− <td><code>POST /api/auth/websocket-ticket</code></td>
985− <td>Environment access token</td>
986− <td>Issue a one-time ticket for the WSS upgrade.</td>
987− </tr>
988− </tbody>
989− </table>
990− </section>
991−
992− <section class="grid" aria-label="Runtime and infrastructure">
993− <article class="panel half">
994− <h2>Relay Client Installation</h2>
995− <p>
996− Desktop web UI availability checks and installs are local WebSocket RPC methods:
997− <code>cloud.getRelayClientStatus</code> and streaming
998− <code>cloud.installRelayClient</code>. The install stream emits the current stage:
999− checking, waiting for lock, downloading, verifying, installing, validating, and
1000− activating.
1001− </p>
1002− <p>
1003− The web UI asks for confirmation before installation and displays a custom progress
1004− dialog. The CLI invokes the same local relay-client service directly and reports the
1005− same progress stages in the terminal.
1006− </p>
1007− </article>
1008−
1009− <article class="panel half">
1010− <h2>Relay Infrastructure</h2>
1011− <ul>
1012− <li>Cloudflare Worker for the hosted relay HTTP API.</li>
1013− <li>PlanetScale Postgres database named <code>t3coderelay</code>.</li>
1014− <li>
1015− Production uses the shared database; non-production stages use database branches.
1016− </li>
1017− <li>
1018− Cloudflare Hyperdrive with caching disabled and a constrained origin connection limit.
1019− </li>
1020− <li>Cloudflare tunnel and DNS bindings for managed endpoint reconciliation.</li>
1021− <li>Cloudflare APNs delivery queue plus dead-letter queue.</li>
1022− <li>A five-minute cron job that prunes expired DPoP replay rows.</li>
1023− <li>Axiom OTLP trace dataset, scoped ingest token, and recent-spans view per stage.</li>
1024− </ul>
1025− </article>
1026− </section>
1027−
1028− <section class="panel" aria-label="Deployment configuration">
1029− <h2>Deployment Configuration</h2>
1030− <table>
1031− <thead>
1032− <tr>
1033− <th>Boundary</th>
1034− <th>Configuration</th>
1035− </tr>
1036− </thead>
1037− <tbody>
1038− <tr>
1039− <td>Relay infrastructure</td>
1040− <td>
1041− <code>RELAY_API_ZONE_NAME</code>, <code>RELAY_TUNNEL_ZONE_NAME</code>, optional
1042− <code>RELAY_DOMAIN</code>, <code>CLERK_PUBLISHABLE_KEY</code>,
1043− <code>CLERK_SECRET_KEY</code>, <code>CLERK_JWT_AUDIENCE</code>, and APNs
1044− credentials.
1045− </td>
1046− </tr>
1047− <tr>
1048− <td>Source-built desktop/server cloud features</td>
1049− <td>
1050− Optional <code>T3CODE_RELAY_URL</code>, <code>T3CODE_CLERK_PUBLISHABLE_KEY</code>,
1051− and <code>T3CODE_CLERK_CLI_OAUTH_CLIENT_ID</code>. Release builds inject public
1052− values at build time.
1053− </td>
1054− </tr>
1055− <tr>
1056− <td>Web and mobile cloud clients</td>
1057− <td>
1058− Public Clerk publishable key, Clerk JWT template name, and relay URL. Relay URLs
1059− must normalize to an absolute HTTPS origin without credentials, query, fragment, or
1060− non-root path.
1061− </td>
1062− </tr>
1063− </tbody>
1064− </table>
1065− </section>
1066−
1067− <section class="panel" aria-label="Implementation checklist">
1068− <h2>Implementation Checklist</h2>
1069− <ul>
1070− <li>
1071− Keep the relay cloud-mint private key hosted-only; install only its public key into
1072− environments.
1073− </li>
1074− <li>
1075− Keep environment signing private keys local and persist keypair creation atomically.
1076− </li>
1077− <li>
1078− Require a local <code>relay:write</code> session before linking or changing relay
1079− config.
1080− </li>
1081− <li>Validate relay URLs as secure absolute HTTPS origins before sending credentials.</li>
1082− <li>Require loopback-only tunnel origins on both sides of the link-proof boundary.</li>
1083− <li>
1084− Resolve managed endpoint egress from ready allocation rows, not client-supplied URLs.
1085− </li>
1086− <li>Disable redirects on relay-to-environment requests.</li>
1087− <li>Validate environment-signed response proofs before consuming tunneled responses.</li>
1088− <li>Keep tunnel and DNS provisioning retry-safe and deprovision them on unlink.</li>
1089− <li>
1090− Revoke shared environment publication credentials only after the final active link
1091− disappears.
1092− </li>
1093− <li>Redact and cap local agent failure details before publishing them externally.</li>
1094− </ul>
1095− </section>
1096−
1097− <section class="panel" aria-label="Standards references">
1098− <h2>Standards References</h2>
1099− <ul class="source-list">
1100− <li>
1101− <a href="https://www.rfc-editor.org/rfc/rfc8252">RFC 8252</a>: browser-based OAuth for
1102− native apps, used by the CLI PKCE authorization flow.
1103− </li>
1104− <li>
1105− <a href="https://www.rfc-editor.org/rfc/rfc8693">RFC 8693</a>: OAuth token exchange,
1106− used for Clerk-token to relay-DPoP-token exchange.
1107− </li>
1108− <li>
1109− <a href="https://www.rfc-editor.org/rfc/rfc9449">RFC 9449</a>: DPoP proof of possession,
1110− used for relay and environment access tokens.
1111− </li>
1112− </ul>
1113− </section>
1114− </main>
1115− <script type="module">
1116− import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
1117−
1118− mermaid.initialize({
1119− startOnLoad: false,
1120− securityLevel: "strict",
1121− theme: "base",
1122− themeVariables: {
1123− fontFamily: 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1124− primaryColor: "#eef4ff",
1125− primaryBorderColor: "#1457d9",
1126− primaryTextColor: "#171717",
1127− lineColor: "#62646b",
1128− signalColor: "#171717",
1129− signalTextColor: "#171717",
1130− noteBkgColor: "#fff6e6",
1131− noteTextColor: "#171717",
1132− actorBkg: "#fbfbf8",
1133− actorBorder: "#62646b",
1134− actorTextColor: "#171717",
1135− },
1136− flowchart: {
1137− htmlLabels: true,
1138− curve: "basis",
1139− padding: 18,
1140− },
1141− sequence: {
1142− showSequenceNumbers: true,
1143− mirrorActors: false,
1144− rightAngles: false,
1145− },
1146− });
1147−
1148− await mermaid.run({ querySelector: ".diagram.mermaid" });
1149−
1150− for (const svg of document.querySelectorAll(".diagram.mermaid svg")) {
1151− const viewBox = svg.getAttribute("viewBox");
1152− const width = viewBox?.split(/\s+/).map(Number)[2];
1153− if (Number.isFinite(width) && width > 0) {
1154− svg.style.setProperty("width", `${Math.ceil(width)}px`, "important");
1155− svg.style.setProperty("max-width", "none", "important");
1156− }
1157− }
1158− </script>
1159− </body>
1160−</html>
docs/internals/t3-connect.md+79−271
@@ -1,273 +1,81 @@
11 # T3 Connect
22
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−T3 Connect uses one Clerk application for web, desktop, and mobile authentication. The relay verifies
6−two kinds of bearer credential: template JWTs generated from the `t3-relay` template with the shared
7−`t3-code-relay` audience, and Clerk OAuth tokens issued to the CLI. `verifyRelayClientBearerToken` in
8−`infra/relay/src/http/Api.ts` tries the template/session path first and falls back to OAuth
9−verification (`acceptsToken: "oauth_token"`), so the CLI's OAuth credential works without a JWT
10−template.
11−
12−For the wider system diagram, see
13−[t3-code-connect-auth-flow.html](./t3-code-connect-auth-flow.html).
14−
15−## Application Keys
16−
17−T3 Connect is disabled in a fresh clone. To enable it for source builds against the production
18−deployment, copy the repository-root example file:
19−
20−```sh
21−cp .env.example .env
22−```
23−
24−`.env.example` carries the production public identifiers (the same values baked into official
25−release builds). To target a different Clerk application or relay, set the values yourself in a
26−repository-root `.env` or `.env.local` file:
27−
28−```dotenv
29−T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key>
30−T3CODE_CLERK_JWT_TEMPLATE=<JWT template name>
31−T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=<public OAuth application client ID>
32−T3CODE_RELAY_URL=https://relay.example.com
33−```
34−
35−The shared client loader projects these canonical values into framework-specific `VITE_*` and
36−`EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new
37−client configuration should use the canonical names.
38−
39−Configuration precedence is:
40−
41−1. Process or CI environment variables.
42−2. Repository-root `.env.local`.
43−3. Repository-root `.env`.
44−
45−The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public
46−identifiers, not secrets.
47−Web, desktop, mobile, and bundled server builds statically inject the values they consume during
48−their build step. A built artifact does not need an environment file at runtime. CI release builds
49−should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`,
50−`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and
51−production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS
52−environment.
53−
54−When any client-facing public value is absent, cloud UI is omitted. The `t3 connect` command group is
55−always registered: when the CLI public values are absent, `makeCli` in `apps/server/src/bin.ts`
56−registers a hidden fallback `connect` command that reports the missing configuration instead of
57−silently vanishing from help. The bundled server still accepts runtime overrides for self-hosted or
58−operator-managed deployments.
59−
60−For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay
61−deployment reads `RELAY_DOMAIN`, `RELAY_API_ZONE_NAME`, `RELAY_TUNNEL_ZONE_NAME`,
62−`CLERK_PUBLISHABLE_KEY`, and `CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in
63−deployment defaults.
64−`vp run --filter t3code-relay deploy` invokes Alchemy from the relay directory, so Alchemy loads
65−`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env`
66−with the deployed HTTPS relay URL. The relay still requires
67−`CLERK_SECRET_KEY` as an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application
68−environment or commit it to the repository.
69−
70−The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference
71−that database and provision isolated PlanetScale branches, so deploy `prod` before creating a
72−personal developer stage.
73−
74−## Headless CLI OAuth Application
75−
76−The `t3 connect` commands authorize a headless environment with a separate Clerk OAuth application.
77−This uses an OAuth public client with PKCE, so the CLI stores no client secret.
78−
79−In **Clerk Dashboard > OAuth applications**:
80−
81−1. Create an OAuth application for the T3 CLI.
82−2. Enable the **Public** option so authorization-code exchange uses PKCE.
83−3. Add **both** allowed redirect URIs:
84− - `http://127.0.0.1:34338/callback` for the loopback listener;
85− - `https://app.t3.codes/connect/callback` for the hosted out-of-band flow. This is
86− `connectCallbackUrl(DEFAULT_HOSTED_APP_URL)` from `packages/shared/src/connectAuth.ts`, so a
87− custom `T3CODE_HOSTED_APP_URL` means `$T3CODE_HOSTED_APP_URL/connect/callback` instead.
88− Omitting it breaks headless and SSH authorization.
89−4. Enable the `openid`, `profile`, and `email` scopes.
90−5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build
91− environment to the generated public client ID.
92−
93−Both CLI flows start at the hosted `/connect` page (`buildConnectAuthorizeRequestUrl` in
94−`packages/shared/src/connectAuth.ts`), which waits for a Clerk session and then forwards the request
95−to Clerk's `/oauth/authorize`. The CLI never opens `/oauth/authorize` directly: a signed-out browser
96−sent there goes through Clerk's sign-in redirect, which drops the authorize query parameters and
97−fails the flow with `unsupported_response_type` or an empty `state` (#5051). The loopback flow marks
98−the request with a `port` fragment parameter so the hosted page asks Clerk to redirect the
99−authorization code straight to `http://127.0.0.1:<port>/callback`; the out-of-band flow omits it and
100−uses the hosted `/connect/callback` page instead. The CLI derives Clerk's frontend API URL from the
101−publishable key and calls only the `/oauth/token` endpoint directly. The relay is not involved in
102−the OAuth handshake; it only validates the issued Clerk bearer token when the CLI manages an
103−environment link.
104−
105−The connect command group is:
106−
107−```sh
108−t3 connect # default: onboarding
109−t3 connect login
110−t3 connect link # --publish-only
111−t3 connect status # --json
112−t3 connect publish # --disable
113−t3 connect unlink
114−t3 connect logout
115−```
116−
117−`t3 serve` is a separate top-level command, not a connect subcommand.
118−
119−`t3 connect login` opens the Clerk authorization flow and stores the CLI credential without enabling
120−cloud exposure. `t3 connect link` installs the pinned managed `cloudflared` binary when needed,
121−authorizes when needed, and records durable intent to expose the environment. It works without a
122−running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the
123−managed tunnel. `t3 connect unlink` records disabled intent immediately, stops a reachable running
124−connector, and attempts to revoke the relay-side environment record. It retains the stored CLI
125−authorization so `t3 connect link` can re-enable exposure without another browser flow. `t3 connect
126−logout` performs the same cleanup and removes the stored CLI authorization.
127−
128−The background service has an independent lifecycle. Connect setup may offer to install it, but
129−logout leaves it running; manage it with `t3 service status`, `install`, `update`, and `uninstall`.
130−
131−### Headless and SSH authorization
132−
133−The loopback OAuth callback listener binds to port `34338`. That path only works when a browser on
134−the same machine can reach it, so `authorizeCli` in `apps/server/src/cli/connect.ts` automatically
135−selects the out-of-band flow when `--headless` is passed or when it detects SSH through
136−`SSH_CONNECTION` or `SSH_TTY`. The out-of-band flow prints the hosted `/connect` authorization URL
137−and accepts a pasted authorization code, so no port is involved.
138−
139−Port forwarding is therefore optional, not required. Forward the port only if you specifically want
140−the loopback flow over SSH:
141−
142−```sh
143−ssh -L 34338:127.0.0.1:34338 <host>
144−```
145−
146−## JWT Template
147−
148−In **Clerk Dashboard > JWT templates**, create a template with:
149−
150−| Setting | Value |
151−| ------- | ---------------------------- |
152−| Name | `t3-relay` |
153−| Claims | `{ "aud": "t3-code-relay" }` |
154−
155−Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set
156−`CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and
157−`CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value
158−is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still
159−selects the concrete relay deployment, but changing that URL does not require a JWT template change.
160−
161−## Desktop OAuth Redirect Allowlist
162−
163−The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme.
164−In **Clerk Dashboard > Native applications**, enable the Native API and add these entries under the
165−mobile SSO redirect allowlist:
166−
167−```text
168−t3code-dev://app/
169−t3code://app/
170−```
171−
172−Local desktop development uses `t3code-dev://app`, while packaged builds use `t3code://app`. Add the
173−matching origin to each Clerk instance's Backend API `allowed_origins` array as well. The development
174−Clerk instance should only need `t3code-dev://app`; the production Clerk instance should only need
175−`t3code://app`. `@clerk/electron` owns the native request adapter, encrypted Clerk token persistence,
176−external-browser OAuth transport, and callback delivery for initial sign-in and linked-account flows.
177−
178−There is currently no Dashboard UI for `allowed_origins`. Preserve any existing entries and update
179−the instance through the Backend API:
180−
181−```sh
182−curl -X PATCH https://api.clerk.com/v1/instance \
183− -H "Content-Type: application/json" \
184− -H "Authorization: Bearer $CLERK_SECRET_KEY" \
185− -d '{"allowed_origins":["t3code://app"]}'
186−```
187−
188−Never put `CLERK_SECRET_KEY` in the desktop app, a client-facing environment file, or a build
189−artifact.
190−
191−## Desktop Passkeys
192−
193−The production macOS bundle ID is `com.t3tools.t3code`. To enable native passkeys:
194−
195−1. Create an explicit macOS App ID for `com.t3tools.t3code` in the Apple Developer portal and enable
196− **Associated Domains**.
197−2. Create a compatible macOS provisioning profile for that App ID and the certificate used to sign
198− the distributed app.
199−3. In Clerk's Native API settings, add an iOS app with the same Apple Team ID and bundle ID. This is
200− also the configuration point for Electron/macOS passkeys.
201−4. Confirm Clerk serves `https://<frontend-api>/.well-known/apple-app-site-association` and that
202− `webcredentials.apps` contains `<TEAM_ID>.com.t3tools.t3code`.
203−5. Set the local or CI signing configuration described below.
204−
205−For a local signed build, add these values to `.env.local` or export them before invoking the
206−desktop artifact command:
207−
208−```dotenv
209−T3CODE_APPLE_TEAM_ID=ABC1234567
210−T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile
211−# Optional: comma-separated override when Clerk's RP ID differs from the Frontend API hostname.
212−T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com
213−```
214−
215−When `T3CODE_CLERK_PASSKEY_RP_DOMAINS` is absent, the build derives the RP domain from
216−`T3CODE_CLERK_PUBLISHABLE_KEY`. Signed macOS builds fail early if the Team ID, provisioning profile,
217−or RP-domain configuration is missing. The generated main-app entitlements include every configured
218−`webcredentials:<domain>` entry; helper apps keep Electron's minimal default entitlements.
219−
220−The normal `dev:desktop` launcher is unsigned and cannot complete macOS passkey ceremonies. For
221−renderer HMR, build and install a signed app first, run the renderer dev server, then launch the
222−installed app executable with `VITE_DEV_SERVER_URL` and `T3CODE_PORT` set. Rebuild the signed app
223−after native dependency, main-process, preload, entitlement, provisioning, or signing changes;
224−renderer-only changes can reuse the installed app.
225−
226−For the default development ports, run `pnpm dev:web` in one terminal and launch the installed
227−binary from another:
228−
229−```sh
230−VITE_DEV_SERVER_URL=http://127.0.0.1:5733 \
231−T3CODE_PORT=13773 \
232− "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)"
233−```
234−
235−After changing Associated Domains, bump the build version before rebuilding; macOS may otherwise
236−reuse stale Shared Web Credentials metadata for the same app/version pair.
237−
238−Verify the installed bundle before testing:
239−
240−```sh
241−codesign --verify --deep --strict "/Applications/T3 Code (Alpha).app"
242−codesign -d --entitlements :- "/Applications/T3 Code (Alpha).app"
243−```
244−
245−The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth
246−flow uses a custom redirect URI, add that exact URI to the same allowlist.
247−
248−## Sign-in Surfaces
249−
250−Signed-in users manage T3 Connect under **Connections**. The settings sidebar also has dedicated
251−controls, rendered by `SettingsSidebarNav.tsx`: `T3ConnectSidebarSignIn` in the footer shows a
252−**Sign in to T3 Connect** button while signed out, and `T3ConnectSidebarAvatar` shows a Clerk
253−`UserButton` account control while signed in. Both are gated on cloud public configuration.
254−Desktop renders the same web bundle, so it has them too. The waitlist enrollment flow from the
255−private beta was removed when Connect went GA; sign-up is open unless a Clerk restriction below is
256−enabled.
257−
258−## Restricting Sign-ups: Known-User Allowlist
259−
260−For a closed deployment where all permitted users are known in advance, restrict sign-up to
261−permitted email addresses or domains:
262−
263−1. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email
264− domain.
265−2. Enable the allowlist and save.
266−3. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or
267− manually created.
268−
269−Do not enable an empty allowlist: it blocks all new sign-ups.
270−
271−Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud
272−access. To remove an already-created user's access, ban that user in Clerk so their active
273−sessions are ended and future sign-ins are rejected.
3+T3 Connect uses Clerk for cloud identity. The relay manages environment links,
4+credentials for reaching environments, and managed tunnel allocations. After
5+bootstrap, clients send application traffic through the environment's tunnel
6+hostname; the relay Worker does not proxy their HTTP or WebSocket sessions.
7+
8+Clerk, deployment, and native authentication setup live in the
9+[Connect setup runbook](../operations/connect-setup.md).
10+
11+## The relay is a trusted broker
12+
13+An authenticated cloud user still needs an active environment link. The relay
14+asks that environment to mint a one-time bootstrap credential bound to the
15+client's DPoP key. The client exchanges it directly with the environment for an
16+[environment session](./environment-auth.md). The relay never receives that
17+session token, and possessing the bootstrap credential alone does not permit
18+redeeming it without the client's private key.
19+
20+Both sides authenticate this exchange. The environment accepts only bounded,
21+replay-guarded relay proofs for its own identity, linked user, and requested
22+operation. Signed environment responses bind the result to the request nonce;
23+mint responses also bind the credential to the client proof key. The relay
24+verifies those bindings before returning a credential. This prevents a different
25+process behind the tunnel from impersonating the linked environment. The checks
26+meet in the
27+[environment cloud handlers](../../apps/server/src/cloud/http.ts) and
28+[relay connector](../../infra/relay/src/environments/EnvironmentConnector.ts).
29+
30+The relay holds the signing authority for mint requests. DPoP protects an honest
31+exchange from credential reuse; it does not make a compromised relay signing
32+key harmless. Keep that trust assumption explicit when changing the protocol.
33+
34+Managed tunnels expose only a validated loopback HTTP origin. Link proof checks
35+reject forwarded authority headers, and the relay resolves endpoints from its
36+own managed allocations rather than a caller-supplied URL. Health and mint
37+requests must not follow redirects. These restrictions keep endpoint discovery
38+from turning into arbitrary relay egress or exposing another service on the
39+environment host.
40+
41+## A link outlives a connector process
42+
43+CLI authorization, desired exposure, and a running connector have different
44+lifetimes. Linking can record intent while the server is stopped. Startup
45+reconciles that intent. CLI logout removes the stored cloud credential and
46+disables exposure without uninstalling the environment's background service.
47+
48+Managed allocations belong to a user/environment pair. Provisioning checkpoints
49+external tunnel and DNS resources so retries can reconcile partial work. A
50+normal shutdown of a CLI-managed link releases its tunnel to avoid paying for
51+an idle resource, retaining the hostname reservation for the next startup.
52+It also retains the allocation record so the environment remains "offline"
53+rather than becoming "not authorized".
54+
55+Two cases must retain the tunnel across shutdown. A link installed through a
56+client has no startup provisioning path and depends on its stored connector
57+token. An update handoff immediately starts a replacement server, and replacing
58+the tunnel would add routing propagation delay to every update. These exceptions
59+belong to [shutdown handling](../../apps/server/src/cloud/http.ts).
60+
61+Release and unlink claim the allocation generation before deleting external
62+resources. A delayed cleanup must not delete a tunnel reused by a concurrent
63+restart or relink. Unlink commits authorization revocation before external
64+teardown, because a database failure must leave the active link usable. Failed
65+teardown retains enough state to retry. See the
66+[managed endpoint lifecycle](../../infra/relay/src/environments/ManagedEndpointProvider.ts).
67+
68+## OAuth traps
69+
70+Interactive clients and the headless CLI use the same Clerk application but
71+different credentials. The relay accepts both session-template JWTs and CLI
72+OAuth tokens; requiring a JWT template for the CLI would reject valid logins.
73+The CLI is a public OAuth client using PKCE and stores no client secret.
74+
75+CLI authorization starts on the hosted `/connect` page so sign-in completes
76+before entering Clerk's authorize endpoint. Sending a signed-out browser
77+straight to that endpoint loses the authorize parameters during the sign-in
78+redirect. The [shared flow](../../packages/shared/src/connectAuth.ts) preserves
79+PKCE and state for both loopback and pasted-code callbacks. SSH and headless
80+sessions use the pasted-code flow because the browser cannot ordinarily reach a
81+listener on the remote machine.
docs/internals/terminal-runtime.md+42−50
@@ -1,52 +1,44 @@
11 # Terminal runtime
22
3−The environment server owns terminal processes, retained output history, and
4−session lifecycle. Web, desktop, and mobile clients attach to the same
5−server-owned session over the environment RPC connection. The desktop renderer
6−does not own a separate PTY. Clients can reconnect to a running PTY or share
7−it with another client.
8−
9−## Output path
10−
11−PTY output follows this path:
12−
13−```text
14−PTY callback
15− -> ordered process-event drain
16− -> bounded retained-history append
17− -> live terminal output event
18− -> coalesced history persistence
19−```
20−
21−Live output events contain only the new PTY data. Full retained history is
22−materialized only when the server returns a snapshot or when the coalescing
23−persistence worker writes the latest state.
24−
25−Retained history is limited to 5,000 lines and 8 MiB of UTF-8 text per terminal.
26−The server discards the oldest output when either limit is reached. A byte
27−cutoff can shorten the oldest retained line, but does not split a Unicode code
28−point. Live output is not truncated.
29−
30−History uses small chunks with byte and newline counts. Appending output scans
31−the new text and any removed chunk prefix, not the full retained history.
32−Empty lines, incomplete final lines, and trailing newlines remain unchanged
33−within the limits. Split surrogate pairs are joined before byte eviction.
34−
35−Discard each chunk's string reference as soon as it leaves retained history.
36−Array compaction can run later. Shared web and mobile client state retains at
37−most 512 KiB, so each client can display less scrollback than the server keeps.
38−
39−Measure sustained-output changes against a full retained history so terminal
40−throughput does not regress unnoticed.
41−
42−## Persistence
43−
44−History persistence is keyed by terminal session and coalesces pending writes.
45−The worker reads the newest bounded-history state after its debounce instead
46−of receiving a newly materialized full string for every PTY callback. Clear,
47−restart, close, and final flush operations still force the latest state to
48−disk before their lifecycle boundary completes.
49−
50−Restoration reads at most the last 8 MiB from current and legacy history files.
51−It skips an incomplete UTF-8 code point at the start and applies the line limit
52−before rewriting oversized files. File handles close before that rewrite.
3+The environment server owns PTYs, session lifetime, and retained output. Every
4+client, including the desktop renderer, attaches through the environment connection.
5+This lets clients reconnect or share a running session. Renderer choices stay local
6+to each client and do not change terminal contracts.
7+
8+## Output and retention
9+
10+[Terminal history](../../apps/server/src/terminal/Manager.ts) is incremental.
11+PTY callbacks append new chunks; live events carry only those chunks. Materializing
12+or copying full scrollback on every callback makes output cost grow with retained
13+history, so snapshots and coalesced persistence are the materialization boundaries.
14+Persistence queues the mutable history buffer and reads its latest value when the
15+write runs. Clear, restart, and close must drain writes before completing their
16+lifecycle boundary.
17+
18+Server history is capped at 5,000 lines and 8 MiB of UTF-8 text per terminal, so a
19+long unterminated line cannot bypass retention. Eviction removes the oldest output
20+without splitting Unicode code points; live output is not truncated. Release
21+discarded chunk references immediately, even if array compaction happens later.
22+Client buffers have a separate 512 KiB cap. Measure throughput with full scrollback
23+when changing this path.
24+
25+Restoration must read only the bounded tail of current or legacy history files,
26+skip any incomplete UTF-8 prefix, and apply the line limit. Close the read handle
27+before rewriting the capped file. Reading whole old logs would defeat the memory
28+bound during startup.
29+
30+## Renderer ownership
31+
32+Android and web use the same `libghostty-vt` C ABI for terminal behavior. Platform
33+adapters own drawing and input integration, and React stays out of terminal frames.
34+The web adapter shares one WebAssembly instance per browser tab while each terminal
35+owns and frees its own handles. The canonical upstream pin is
36+[`native/libghostty-vt/VERSION`](../../native/libghostty-vt/VERSION); both native and
37+web artifacts must be rebuilt when it changes. Web embeds the revision in its build
38+info so the ABI check can detect drift without a second pin.
39+
40+Restoring scrollback must not send terminal replies to the current shell. Historical
41+device queries can otherwise provoke fresh replies that appear as junk at the
42+prompt. The server strips query/response traffic from retained history, and the
43+[web renderer](../../apps/web/src/terminal/ghostty/core.ts) detaches its PTY writer
44+during replay. Preserve both protections when changing retention or renderer code.
docs/internals/voice-input.md+18−100
@@ -1,102 +1,20 @@
11 # Voice input
22
3−> For maintainers. Using T3 Code? See [voice input on iPhone](../user/composer.md#voice-input-on-iphone).
4−
5−Voice input produces editable composer text. The current implementation records on the client and
6−transcribes locally with Apple's `SpeechAnalyzer` and `SpeechTranscriber` on supported iOS 26+
7−devices. Environment-provided transcription and transcription on web and desktop are not implemented.
8−
9−## Current boundaries
10−
11−The shared [`VoiceInputController`][controller] in `packages/client-runtime` owns preparation,
12−recording, transcription, cancellation, temporary-file cleanup, and insertion into the captured
13−draft selection. Applications import it through the [voice-input entry point][voice-input] as
14−`@t3tools/client-runtime/voice-input`. Its dependencies separate capture from transcription; the
15−controller imports neither React Native nor an Apple transcription API.
16−
17−The shared [transcription contract][transcription] defines `VoiceTranscriber`,
18−`PreparedVoiceTranscription`, and transcription errors. The controller calls `getTranscriber()` once
19−at the start of an operation, before asking for microphone permission. Preparation returns a resolved
20−locale and a bound `transcribe` function. The controller retains that result for the recording, so a
21−selection change cannot prepare with one implementation and transcribe with another.
22−
23−[`useVoiceInputController`][hook] supplies Expo audio capture, microphone permissions, audio-session
24−management, waveform samples, and app and navigation lifecycle handling. It normalizes Expo's
25−`mediaServicesDidReset` into a generic recorder error. [`voiceTranscription.ios.ts`][ios] adapts
26−`@react-native-ai/apple` through `getLocalVoiceTranscriber()`, capturing the requested device locale
27−and binding the prepared transcriber to Apple's resolved locale. The other-platform binding returns
28−no local transcriber. That result describes the local implementation, not whether a client could use
29−an environment's transcription service.
30−
31−Mobile's [`voiceInputPresentation.ts`][presentation] maps shared state to toolbar labels and actions.
32−Waveform and toolbar rendering stay in mobile. The composer edits draft text without selecting a
33−speech vendor.
34−Recording captures the draft owner, revision, text, and selection. A late transcript cannot overwrite
35−a different or edited draft. Only normal message submission sends the resulting text to an agent.
36−
37−Each operation passes one `AbortSignal` through preparation and transcription. Cancellation
38−invalidates the operation and aborts that signal immediately. Implementations settle their promises
39−only after their underlying work stops. The Apple binding checks cancellation between asynchronous
40−steps but cannot interrupt an in-flight native request. The controller retains its session until
41−that work settles, ignores its result, and cleans up the recording.
42−
43−## Ownership decisions
44−
45−The extension boundary distinguishes transcription on the client device from transcription through
46−the composer's environment. These constraints apply when adding selectable transcription services:
47−
48−- Local means the client device, regardless of which machine hosts the environment. A device's lack
49− of local recognition does not prevent it from recording audio for an environment service.
50−- Remote service configuration and API keys belong to the environment. The environment calls the
51− external service. Clients receive service identifiers, labels, and availability information, never
52− credential values. Transcription services are independent of coding-agent `providerInstances`;
53− selecting OpenAI for transcription does not select Codex for the thread.
54−- The client owns its transcription preference, scoped by stable `environmentId`. Its choices are
55− supported local recognition and the services exposed by the composer's environment. A service ID
56− is meaningful only within that environment. Different clients can make different choices.
57−- Resolve and capture the environment, selected service, and locale when an operation starts.
58− Preparation and transcription use the same selection; preference changes affect the next
59− recording. Capture environment identity explicitly rather than recovering it from a draft key.
60− Keep the existing draft-owner and revision checks before inserting text.
61−- If the selected option is unavailable, report that state and let the user choose another option.
62− A local failure must not silently upload audio, and a disconnected environment must not redirect
63− a recording to another environment or service.
64−- Transcription audio is temporary input, separate from durable chat attachments and messages.
65− Remote adapters need cancellation of upload and transcription where supported, cleanup after
66− success, failure, or cancellation, and the same protection against late results as local transcription.
67−
68−## Existing integration points
69−
70−[`ServerSettingsService`][settings] and [`ServerSecretStore`][secrets] provide environment-owned
71−configuration and secret persistence. Existing settings redaction handles coding-provider environment
72−variables only. Any transcription credential fields need their own explicit separation and redaction
73−before settings responses or subscriptions reach client caches.
74−
75−[`ExecutionEnvironmentCapabilities`][capabilities] handles version skew. Remote transcription must be
76−opt-in: a missing transcription capability means unsupported. The authenticated server-config
77−subscription and [shared environment state][server-state] already distribute configuration per
78−environment. A transcription service catalog belongs behind that capability and authenticated
79−boundary. Older servers expose no remote transcription choices.
80−
81−The [attachment upload contracts][uploads] and [shared upload operations][attachment-state] provide a
82−pattern for authorized binary uploads through an environment, including remote connections. Their
83−existing chat-attachment retention is not a transcription cleanup policy.
84−
85−Future service selection and environment requests belong alongside the controller in
86−`packages/client-runtime`, with wire contracts in `packages/contracts`. Capture and native local
87−recognition remain client-specific. An environment-backed transcriber implements the same shared
88−contract, with its environment and service bound when selected. The controller does not own service
89−credentials, provider SDKs, or transport selection.
90−
91−[controller]: ../../packages/client-runtime/src/voice-input/controller.ts
92−[voice-input]: ../../packages/client-runtime/src/voice-input/index.ts
93−[transcription]: ../../packages/client-runtime/src/voice-input/transcription.ts
94−[hook]: ../../apps/mobile/src/features/voice-input/useVoiceInputController.ts
95−[presentation]: ../../apps/mobile/src/features/voice-input/voiceInputPresentation.ts
96−[ios]: ../../apps/mobile/src/native/voiceTranscription.ios.ts
97−[settings]: ../../apps/server/src/serverSettings.ts
98−[secrets]: ../../apps/server/src/auth/ServerSecretStore.ts
99−[capabilities]: ../../packages/contracts/src/environment.ts
100−[server-state]: ../../packages/client-runtime/src/state/server.ts
101−[uploads]: ../../packages/contracts/src/assets.ts
102−[attachment-state]: ../../packages/client-runtime/src/state/attachments.ts
3+Transcription edits a composer draft. It does not submit an agent turn. Audio is
4+temporary client input, and only normal message submission sends the resulting
5+text. The current implementation transcribes locally on supported iOS devices;
6+environment-backed transcription is not implemented.
7+
8+The [shared controller](../../packages/client-runtime/src/voice-input/controller.ts)
9+owns the operation while the client supplies capture and transcription. Preparation
10+binds the transcriber and resolved locale for the whole recording. Draft ownership,
11+text, and revision are captured before recording and checked before insertion, so
12+a late transcript cannot overwrite a draft that was edited or replaced.
13+
14+Cancellation invalidates a result immediately, but resources stay owned until the
15+underlying work settles. Apple's native transcription call cannot be interrupted
16+once started. Releasing the session or deleting its recording when the abort signal
17+fires would race that work. The [transcription contract](../../packages/client-runtime/src/voice-input/transcription.ts)
18+therefore requires implementations to settle only after their work has stopped;
19+the [Apple binding](../../apps/mobile/src/native/voiceTranscription.ios.ts) checks
20+cancellation between native calls and discards late results.
docs/internals/work-artifacts.md+0−25
@@ -1,25 +0,0 @@
1−# Engineering work artifacts
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−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.
6−
7−## Current facts belong in the docs
8−
9−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.
10−
11−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.
12−
13−## Planned work belongs in GitHub
14−
15−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.
16−
17−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.
18−
19−## Temporary work stays temporary
20−
21−Keep agent scratch files, exploratory research, transcripts, and session handoff notes outside the worktree. They are inputs to the work, not project documentation.
22−
23−`.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.
24−
25−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.
docs/internals/workspace-layout.md+0−63
@@ -1,63 +0,0 @@
1−# Workspace layout
2−
3−> For maintainers. Using T3 Code? See [docs/user](../user/).
4−
5−A pnpm workspace driven by [vite-plus](https://vite.plus) (`vp`). See [scripts.md](./scripts.md) for
6−the task commands.
7−
8−## apps
9−
10−- `apps/server` (`t3`): the execution runtime and the published CLI. Owns orchestration, provider
11− drivers, checkpointing, VCS, terminals, filesystem access, auth, and the HTTP + WebSocket surface.
12− Also serves the built web app.
13−- `apps/web` (`@t3tools/web`): React + Vite UI. Consumes the shared client runtime and adds routing,
14− components, and web-specific platform layers.
15−- `apps/desktop` (`@t3tools/desktop`): Electron shell. Supervises a desktop-scoped `t3` backend,
16− loads the web bundle over the `t3code://` protocol, and owns SSH-managed remote environments.
17−- `apps/mobile` (`@t3tools/mobile`): Expo/React Native client. Same client runtime composition as
18− web, different platform layer and UI.
19−- `apps/marketing` (`@t3tools/marketing`): Astro marketing site.
20−
21−## packages
22−
23−- `packages/contracts` (`@t3tools/contracts`): shared Effect Schema definitions. RPC group,
24− orchestration commands/events/read model, auth scopes, environment descriptors, settings.
25−- `packages/shared` (`@t3tools/shared`): framework-agnostic utilities used by server and clients
26− (`DrainableWorker`, git and source-control helpers, relay auth and signing, DPoP, semver, logging,
27− observability, and more).
28−- `packages/client-runtime` (`@t3tools/client-runtime`): connection lifecycle, authorization, RPC
29− session, environment registry, and Atom-based domain state shared by web and mobile. See its
30− [README](../../packages/client-runtime/README.md).
31−- `packages/ssh` (`@t3tools/ssh`): SSH config parsing, auth prompts, command execution, and the
32− tunnel/environment manager behind desktop-managed SSH environments.
33−- `packages/tailscale` (`@t3tools/tailscale`): Tailscale CLI wrapper, including the
34− `ensureTailscaleServe` / `disableTailscaleServe` serve lifecycle the server drives.
35−- `packages/effect-acp` (`effect-acp`): Effect client and agent implementation of the Agent Client
36− Protocol, used by ACP-speaking provider drivers.
37−- `packages/effect-codex-app-server` (`effect-codex-app-server`): Effect client for the
38− `codex app-server` JSON-RPC protocol.
39−
40−## infra
41−
42−- `infra/relay` (`t3code-relay`): the hosted T3 Connect relay, deployed with Alchemy. Handles
43− environment discovery, cloud-side records, and mobile notifications. It is not in the hot path;
44− after connect, client traffic goes directly to the environment. See
45− [t3-connect.md](./t3-connect.md).
46−
47−## Other top-level directories
48−
49−- `scripts/`: workspace tooling run through `vp run`. Dev runner, desktop artifact builds, release
50− helpers, mobile static checks and showcase capture, update-manifest merging.
51−- `assets/`: brand and app icon sources per channel (`dev`, `nightly`, `prod`).
52−- `patches/`: pnpm patches for pinned upstream dependencies.
53−- `oxlint-plugin-t3code/`: repo-specific lint rules.
54−- `experiments/`: throwaway prototypes. Not part of the shipped build.
55−- `docs/`: this documentation tree.
56−
57−## Import conventions
58−
59−`@t3tools/shared` and `@t3tools/client-runtime` use explicit subpath exports with no barrel index and
60−no root export. Import the narrow path (`@t3tools/shared/DrainableWorker`,
61−`@t3tools/client-runtime/state/threads`) rather than the package root. Files that are not exported
62−are implementation details. `@t3tools/contracts` does export a root alongside `./settings` and
63−`./relay`.
docs/operations/connect-setup.md+126−0
@@ -0,0 +1,126 @@
1+# T3 Connect setup
2+
3+Deployment and client configuration for T3 Connect. The [architecture note](../internals/t3-connect.md)
4+explains the trust boundaries; the [relay README](../../infra/relay/README.md#deployment) owns relay
5+provisioning instructions.
6+
7+## Public application configuration
8+
9+T3 Connect is disabled in a fresh clone. To build against the production deployment, copy the
10+repository-root example:
11+
12+```sh
13+cp .env.example .env
14+```
15+
16+For another deployment, set these values in the repository-root `.env` or `.env.local`:
17+
18+```dotenv
19+T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key>
20+T3CODE_CLERK_JWT_TEMPLATE=<JWT template name>
21+T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=<public OAuth application client ID>
22+T3CODE_RELAY_URL=https://relay.example.com
23+```
24+
25+Process variables take precedence over `.env.local`, then `.env`. Use these canonical names;
26+the build loader supplies framework-specific aliases. These values are public identifiers.
27+`CLERK_SECRET_KEY` belongs only in the relay's secrets, never in client configuration.
28+
29+Client and bundled-server builds embed the public values, so set them before building.
30+EAS preview and production environments need the publishable key, JWT template name, and relay URL.
31+Bundled servers also accept runtime overrides for operator-managed deployments.
32+
33+Copy `infra/relay/.env.example` to `infra/relay/.env` for relay deployment settings.
34+Deploy `prod` before personal stages because it owns the retained database that their branches
35+depend on. The deploy wrapper writes the resulting relay URL back to the root `.env`.
36+
37+## CLI OAuth application
38+
39+In Clerk's OAuth applications settings:
40+
41+1. Create a public OAuth application for the T3 CLI, using authorization-code exchange with PKCE.
42+2. Allow both redirect URIs: `http://127.0.0.1:34338/callback` and
43+ `https://app.t3.codes/connect/callback`. A custom `T3CODE_HOSTED_APP_URL` needs its own
44+ `/connect/callback` URL. Headless and SSH authorization depend on the hosted redirect.
45+3. Enable the `openid`, `profile`, and `email` scopes.
46+4. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` to the generated public client ID in local and release
47+ build environments.
48+
49+## JWT template
50+
51+Create a Clerk JWT template named `t3-relay` with claims:
52+
53+```json
54+{ "aud": "t3-code-relay" }
55+```
56+
57+Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` for clients and
58+`CLERK_JWT_AUDIENCE=t3-code-relay` for the relay. The production relay deployment environment
59+also defines `CLERK_JWT_TEMPLATE`. The audience stays the same across relay stages; the relay
60+URL selects the deployment.
61+
62+## Desktop OAuth redirects
63+
64+Enable Clerk's Native API and add the desktop redirects to its SSO redirect allowlist:
65+
66+```text
67+t3code-dev://app/
68+t3code://app/
69+```
70+
71+Add the corresponding origin to the Clerk instance's Backend API `allowed_origins` array.
72+Development uses `t3code-dev://app`; production uses `t3code://app`. Update the array with
73+`PATCH https://api.clerk.com/v1/instance` using the Clerk secret key, preserving existing entries.
74+The Clerk Electron integration handles token
75+persistence and system-browser callback delivery.
76+
77+## Desktop passkeys
78+
79+For a production macOS app with bundle ID `com.t3tools.t3code`:
80+
81+1. Create an explicit macOS App ID in the Apple Developer portal with **Associated Domains**.
82+2. Create a provisioning profile for that App ID and the distribution signing certificate.
83+3. In Clerk's Native API settings, add an iOS app with the same Apple Team ID and bundle ID.
84+ This setting also configures Electron/macOS passkeys.
85+4. Check `https://<frontend-api>/.well-known/apple-app-site-association`. Its
86+ `webcredentials.apps` must include `<TEAM_ID>.com.t3tools.t3code`.
87+5. Configure signing as described in the [release runbook](./release.md#2-apple-signing--notarization-setup-macos).
88+
89+Local signed builds additionally use:
90+
91+```dotenv
92+T3CODE_APPLE_TEAM_ID=ABC1234567
93+T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile
94+# Override only when the RP domain differs from the Clerk Frontend API hostname.
95+T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com
96+```
97+
98+Without the override, the build derives the RP domain from the Clerk publishable key.
99+After changing Associated Domains, bump the build version before rebuilding. macOS can otherwise
100+reuse stale Shared Web Credentials metadata for the same app/version pair.
101+
102+The ordinary `dev:desktop` launcher is unsigned and cannot exercise macOS passkeys. For renderer
103+HMR, install a signed build, start `vp run dev:web`, and launch the installed executable with the
104+actual web and server ports. For example, with the default ports:
105+
106+```sh
107+VITE_DEV_SERVER_URL=http://127.0.0.1:5733 \
108+T3CODE_PORT=13773 \
109+ "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)"
110+```
111+
112+Rebuild the signed app after native dependency, main-process, preload, entitlement, provisioning,
113+or signing changes. Renderer edits can reuse it. Verify the installed bundle before testing:
114+
115+```sh
116+codesign --verify --deep --strict "/Applications/T3 Code (Alpha).app"
117+codesign -d --entitlements :- "/Applications/T3 Code (Alpha).app"
118+```
119+
120+## Restricting sign-ups
121+
122+Use Clerk's allowlist for permitted email addresses or domains, or Restricted mode for invitation-only
123+sign-up. An enabled empty allowlist blocks all new sign-ups.
124+
125+Sign-up restrictions do not revoke an existing account's access. Ban the account in Clerk when
126+its active sessions and future sign-ins must be disabled.
docs/operations/development.md+140−0
@@ -0,0 +1,140 @@
1+# Development
2+
3+## First checkout
4+
5+Install `vp` using the [root README](../../README.md#install-vp). The checkout requires Node 24;
6+Bun is optional. From the repository root:
7+
8+```sh
9+vp i
10+vp run dev
11+```
12+
13+Open the one-time pairing URL printed by the dev runner. The bare origin does not authenticate
14+a new browser.
15+
16+## Choosing a dev process
17+
18+Use `vp run dev` for server and web, or `vp run dev:desktop` for the Electron client.
19+`dev:server` and `dev:web` start those processes separately.
20+See the [mobile README](../../apps/mobile/README.md) for native builds and Metro.
21+
22+Flags go directly after the task name, for example `vp run dev --home-dir /tmp/t3code-dev`.
23+Add `--browser` to open a browser automatically.
24+
25+### State and ports
26+
27+Linked worktrees default to their own `.t3/userdata`, even when `T3CODE_HOME` is set.
28+The main checkout defaults to `~/.t3/dev/userdata`. An explicit `--home-dir` wins in both cases.
29+Never run a development server against the live `~/.t3/userdata`.
30+See [test data](../../AGENTS.md#test-data) for copying a consistent database snapshot.
31+
32+Read ports from the `[dev-runner]` output. Worktrees derive stable preferences from their paths,
33+but occupied ports can shift them. `T3CODE_PORT_OFFSET` or `T3CODE_DEV_INSTANCE` can select a
34+different preference when needed.
35+
36+### Sharing and remote debugging
37+
38+`vp run dev --share` publishes the web port over the machine's tailnet and prints a pairing URL
39+for that origin. Give the tester the complete URL, including its token. The dev runner removes
40+its mapping on exit.
41+
42+Leave `VITE_HTTP_URL` and `VITE_WS_URL` unset. Vite proxies the backend through the browser's
43+origin so the same build works over localhost and remote connections.
44+
45+Shared runs enable bundled dev to avoid a network round trip for each import level.
46+`T3CODE_BUNDLED_DEV=0` opts out when debugging bundler differences. Two reload traps matter
47+when changing this setup:
48+
49+- The web entry must dynamically import the app so React refresh initializes before application
50+ chunks. Static imports can work on first load and fail after a route split.
51+- Bundled dev rebuilds Tailwind through watched files. Its ordinary Vite hot-update hook expects
52+ a server/module graph that Rolldown does not provide.
53+
54+The workarounds live in the [web entry](../../apps/web/src/bootstrap.ts) and
55+[Tailwind plugin](../../apps/web/vite/tailwind.ts).
56+
57+## Checks
58+
59+Run checks for the files and packages you changed:
60+
61+```sh
62+vp test run <files>
63+vp lint <files>
64+vp run --filter <package> typecheck
65+```
66+
67+Use `vp run lint:mobile` for native mobile changes. CI owns the full suite; see
68+[ci.yml](../../.github/workflows/ci.yml) for its current jobs.
69+The [manual Windows lane](../../.github/workflows/windows-tests.yml) is available for focused
70+Windows investigation while that suite is not a required gate.
71+
72+## Desktop artifacts
73+
74+Local artifact builds are unsigned by default and write to `release/`:
75+
76+```sh
77+vp run dist:desktop:dmg
78+vp run dist:desktop:linux
79+vp run dist:desktop:win
80+```
81+
82+DMGs default to the host architecture. Use `--arch` to choose another target and `--keep-stage`
83+to retain packaging files for inspection. Run `vp run dist:desktop:artifact --help` for other
84+options.
85+
86+### Linux AppImage prerequisites
87+
88+Build on Linux because the browser-secret helper links against the host's libsecret. Install
89+Rust, C/C++ build tools, libsecret development headers, pkg-config, and ImageMagick.
90+
91+Ubuntu and Debian:
92+
93+```sh
94+sudo apt-get update
95+sudo apt-get install cargo rustc build-essential libsecret-1-dev pkg-config imagemagick
96+```
97+
98+Fedora:
99+
100+```sh
101+sudo dnf install rust cargo gcc gcc-c++ make libsecret-devel pkgconf-pkg-config ImageMagick
102+```
103+
104+Arch Linux:
105+
106+```sh
107+sudo pacman -S rust base-devel libsecret pkgconf imagemagick
108+```
109+
110+The C toolchain, pkg-config, and libsecret headers are also needed for Linux desktop development.
111+
112+### macOS DMG prerequisites
113+
114+Install the Xcode Command Line Tools with `xcode-select --install` and install Rust.
115+For a cross-architecture or universal build, add the requested Rust targets:
116+
117+```sh
118+rustup target add aarch64-apple-darwin x86_64-apple-darwin
119+```
120+
121+### Windows installer prerequisites
122+
123+Install Rust, Python 3, and Visual Studio Build Tools with **Desktop development with C++**.
124+Include the Windows SDK and the MSVC build tools and Spectre-mitigated libraries for the target
125+architecture. Add its Rust target:
126+
127+```powershell
128+rustup target add x86_64-pc-windows-msvc
129+# For an ARM64 installer:
130+rustup target add aarch64-pc-windows-msvc
131+```
132+
133+NSIS is downloaded by electron-builder. WSL support additionally needs a Linux node-pty prebuild;
134+see the [release runbook](./release.md#windows-payload-topology-and-update-validation).
135+
136+### Signing and passkeys
137+
138+Add `--signed` after configuring the platform credentials in the
139+[release runbook](./release.md). macOS passkeys need a signed, provisioned app; follow the
140+[Connect setup](./connect-setup.md#desktop-passkeys) for local signing and renderer HMR.
docs/operations/release.md+1−1
@@ -329,7 +329,7 @@ Checklist: · 328 unmodified lines
329329 - `APPLE_API_KEY`: contents of the downloaded `.p8`
330330 - `APPLE_API_KEY_ID`: Key ID
331331 - `APPLE_API_ISSUER`: Issuer ID
332−10. Complete the Clerk Native API and AASA setup in [T3 Connect Clerk Setup](../internals/t3-connect.md#desktop-passkeys).
332+10. Complete the Clerk Native API and AASA setup in [T3 Connect setup](./connect-setup.md#desktop-passkeys).
333333 11. Re-run a tag release and confirm macOS artifacts are signed/notarized and contain the expected
334334 `com.apple.developer.associated-domains` entitlement.
335335
infra/relay/README.md+3−5
@@ -9,7 +9,7 @@ optional mobile notifications and Live Activities. · 8 unmodified lines
99
1010 The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects,
1111 regular API and WebSocket traffic goes directly between that client and the selected environment.
12−See the [T3 Connect architecture overview](../../docs/internals/t3-code-connect-auth-flow.html) for the larger system
12+See the [T3 Connect architecture note](../../docs/internals/t3-connect.md) for the larger system
1313 design.
1414
1515 ## Responsibilities
@@ -159,8 +159,6 @@ and hosted web builds. · 143 unmodified lines
159159
160160 See:
161161
162−- [T3 Connect Clerk Setup](../../docs/internals/t3-connect.md) for Clerk keys, JWT templates, and sign-up restrictions
163− setup.
162+- [T3 Connect setup](../../docs/operations/connect-setup.md) for Clerk keys, JWT templates, and sign-up restrictions.
164163 - [Relay Observability](../../docs/operations/relay-observability.md) for deployment tracing and diagnostics.
165−- [T3 Connect Architecture Overview](../../docs/internals/t3-code-connect-auth-flow.html) for the full link,
166− connect, endpoint, and notification flows.
164+- [T3 Connect architecture](../../docs/internals/t3-connect.md) for environment linking and trust boundaries.
scripts/build-desktop-artifact.ts+2−2
@@ -348,7 +348,7 @@ export class LinuxDesktopBuildPrerequisitesMissingError extends Schema.TaggedErr · 347 unmodified lines
348348 ? ["Add the requested Rust target with:", ` rustup target add ${this.rustTarget}`]
349349 : []),
350350 "",
351− "For other distributions, see docs/internals/scripts.md#linux-appimage-prerequisites.",
351+ "For other distributions, see docs/operations/development.md#linux-appimage-prerequisites.",
352352 "Then rerun `vp run dist:desktop:linux`.",
353353 ].join("\n");
354354 }
@@ -420,7 +420,7 @@ export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedE · 65 unmodified lines
420420 "Install Rust from https://rustup.rs and add the requested target:",
421421 ` rustup target add ${this.rustTarget}`,
422422 "Install Python 3 and the Visual Studio Build Tools components listed in",
423− "docs/internals/scripts.md#windows-installer-prerequisites.",
423+ "docs/operations/development.md#windows-installer-prerequisites.",
424424 "",
425425 "Then rerun the desktop artifact command.",
426426 ].join("\n");

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t