Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ apps/web/src/components/__screenshots__
.vitest-*
__screenshots__/
.tanstack
.expo/
/App.tsx
/app.json
ios/
117 changes: 117 additions & 0 deletions REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

Use this when you want to open T3 Code from another device (phone, tablet, another laptop).

## Expo mobile remote

This repo now includes an Expo app at `apps/mobile` that talks to the same T3 WebSocket orchestration API as the web client.

Start it with:

```bash
bun run dev:mobile
```

In the mobile app, enter:

- `Server URL`: for example `http://192.168.1.42:3773`
- `Auth token`: the same token you passed to `--auth-token` when starting the server, if any

The app derives the authenticated WebSocket URL automatically and lets you:

- browse existing threads by project
- open a thread and watch assistant streaming output
- send the next user turn
- answer approval requests and pending user-input prompts
- stop a running turn

## CLI ↔ Env option map

The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:
Expand Down Expand Up @@ -50,6 +73,7 @@ Notes:
- `--host 0.0.0.0` listens on all IPv4 interfaces.
- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions.
- Ensure your OS firewall allows inbound TCP on the selected port.
- For the Expo app in `apps/mobile`, use the same HTTP origin in the connection form and paste the token into the token field.

## 2) Tailnet / Tailscale access

Expand All @@ -66,3 +90,96 @@ Open from any device in your tailnet:
`http://<tailnet-ip>:3773`

You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure.

---

## Architecture Deep-Dive

### Connection Establishment Sequence

#### 1. CLI server (`apps/server/src/cli.ts`)

- Binds the HTTP + WebSocket server directly on the selected host and port
- Uses `--auth-token` / `T3CODE_AUTH_TOKEN` for authenticated remote access
- Serves the same orchestration RPC surface the web app uses locally

#### 2. Mobile — Connection Flow (`apps/mobile/src/app/useRemoteAppState.ts`)

On app mount:

1. **Load saved connection** from secure storage (`expo-secure-store` on native, `AsyncStorage` on web)
2. **Check for deep link** — if a QR or deep link is used, the URL scheme triggers the app with `serverUrl` + `authToken`
3. If neither exists, show the **connection editor sheet**
4. Once credentials are available:
- `resolveRemoteConnection()` normalizes the URL, infers ws/wss protocol, builds the WebSocket URL
- `preflightRemoteConnection()` does HTTP GET to `/api/remote/health` (5s timeout)
- Credentials saved to secure storage
- Creates `RemoteClient` and calls `connect()`

### RPC Protocol (`apps/mobile/src/lib/remoteClient.ts`)

Custom RPC message protocol over WebSocket:

| Message Type | Purpose |
| ------------- | ------------------------------------ |
| **Request** | Unary RPC call (request -> response) |
| **Ack** | Response to a Request |
| **Chunk** | Streaming data (for subscriptions) |
| **Ping/Pong** | Keep-alive every 5s |
| **Exit** | Stream completion |
| **Defect** | Error response |

**Two RPC patterns:**

- **Unary**: `getSnapshot`, `dispatchCommand`, `getThreadMessagesPage`
- **Stream**: `subscribeOrchestrationDomainEvents` — server pushes Chunk messages as events occur

### Real-Time Data Flow

```
Backend emits OrchestrationEvent
-> Server's subscribeOrchestrationDomainEvents stream
-> RPC Chunk message over WebSocket
-> Mobile RemoteClient.onChunk callback
-> useRemoteAppState.applyRealtimeEvent()
-> React state update -> UI re-renders
```

**Snapshot bootstrapping**: On connect, the client calls `getSnapshot` for the full read model (projects, threads, messages), then subscribes to domain events for incremental updates. Events are **sequence-ordered** — out-of-order events are buffered until the gap fills.

### Sending Commands (Mobile -> Server)

```
User taps Send
-> enqueueThreadMessage() (optimistic UI update)
-> client.dispatchCommand("thread.turn.start", payload)
-> RPC Request sent directly to the CLI websocket server
-> Backend processes, emits events back through stream
```

### Reconnection & Resilience

- **Exponential backoff**: 500ms -> 1s -> 2s -> 4s -> 8s (caps at 8s)
- **Ping/keep-alive**: Every 5s; closes socket if no pong within 5s
- **Request timeout**: 60s per request
- **Grace period**: 2.5s before showing "reconnecting" UI (avoids flash on brief drops)
- **Preflight errors**: 401 = bad token, 503 = backend not ready, network error = unreachable

### Security Model

- Token validated on every HTTP request and WebSocket upgrade
- Mobile stores credentials in `expo-secure-store` (encrypted on native)
- Auth via query param on WebSocket URL

### Key Files

| Layer | File | Role |
| ------ | ------------------------------------------ | ------------------------------------- |
| Shared | `packages/shared/src/remote.ts` | Deep link URL builder/parser |
| Mobile | `apps/mobile/src/lib/connection.ts` | URL resolution, preflight check |
| Mobile | `apps/mobile/src/lib/remoteClient.ts` | WebSocket RPC client |
| Mobile | `apps/mobile/src/lib/storage.ts` | Secure credential persistence |
| Mobile | `apps/mobile/src/app/useRemoteAppState.ts` | Central state management |
| Server | `apps/server/src/cli.ts` | Host/port/auth-token remote surface |
| Server | `apps/server/src/http.ts` | Remote health endpoint |
| Server | `apps/server/src/ws.ts` | RPC server endpoints, event streaming |
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"main": "dist-electron/main.js",
"scripts": {
"dev": "bun run --parallel dev:bundle dev:electron",
"dev": "bun run scripts/dev.mjs",
"dev:bundle": "tsdown --watch",
"dev:electron": "bun run scripts/dev-electron.mjs",
"build": "tsdown",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/scripts/dev-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function startApp() {
}

const app = spawn(
resolveElectronPath(),
resolveElectronPath({ development: true }),
[`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"],
{
cwd: desktopDir,
Expand Down
118 changes: 118 additions & 0 deletions apps/desktop/scripts/dev.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const desktopDir = resolve(__dirname, "..");
const bunExecutable = process.execPath;
const childScriptNames = ["dev:bundle", "dev:electron"];
const forcedShutdownTimeoutMs = 1_500;

const children = new Map();
let shuttingDown = false;
let forcedShutdownTimer = null;
let exitCode = 0;
let exitSignal = null;

function maybeExit() {
if (children.size > 0) {
return;
}

if (forcedShutdownTimer !== null) {
clearTimeout(forcedShutdownTimer);
forcedShutdownTimer = null;
}

if (exitSignal !== null) {
process.kill(process.pid, exitSignal);
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signal re-delivery caught by own once handler

Low Severity

When a child process dies from a signal during shutdown, maybeExit calls process.kill(process.pid, exitSignal) to re-signal itself. However, the process.once("SIGTERM") handler is still registered and catches that signal, calling shutdown() which returns early since shuttingDown is already true. The once handler is then removed, but no process.exit() is ever called. The event loop drains and the process exits with code 0 instead of the intended non-zero exit code. The signal handlers need to be removed before re-signaling, e.g. via process.removeAllListeners on the target signal.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 16ed6cc. Configure here.

}

process.exit(exitCode);
}

function stopRemainingChildren() {
for (const child of children.values()) {
child.kill("SIGTERM");
}

if (forcedShutdownTimer !== null) {
return;
}

forcedShutdownTimer = setTimeout(() => {
for (const child of children.values()) {
child.kill("SIGKILL");
}
}, forcedShutdownTimeoutMs);
forcedShutdownTimer.unref();
}

function shutdown({ code = 0, signal = null } = {}) {
if (shuttingDown) {
if (code !== 0 && exitCode === 0) {
exitCode = code;
}
if (signal !== null && exitSignal === null) {
exitSignal = signal;
}
return;
}

shuttingDown = true;
exitCode = code;
exitSignal = signal;
stopRemainingChildren();
maybeExit();
}

function startChild(scriptName) {
const child = spawn(bunExecutable, ["run", scriptName], {
cwd: desktopDir,
env: process.env,
stdio: "inherit",
});

children.set(scriptName, child);

child.once("error", (error) => {
console.error(`[desktop-dev] Failed to start ${scriptName}`, error);
children.delete(scriptName);
shutdown({ code: 1 });
});

child.once("exit", (code, signal) => {
children.delete(scriptName);

if (shuttingDown) {
if (code !== null && code !== 0 && exitCode === 0) {
exitCode = code;
}
if (signal !== null && exitSignal === null) {
exitSignal = signal;
}
maybeExit();
return;
}

if (signal !== null) {
shutdown({ signal });
return;
}

shutdown({ code: code ?? 1 });
});
}

for (const scriptName of childScriptNames) {
startChild(scriptName);
}

process.once("SIGINT", () => {
shutdown({ code: 130 });
});

process.once("SIGTERM", () => {
shutdown({ code: 143 });
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signal lost in dev.mjs shutdown, uses exit code instead

Low Severity

The SIGINT and SIGTERM handlers call shutdown() with only a code (130 / 143) but no signal property. The maybeExit function (line 27-28) only re-raises the signal via process.kill(process.pid, exitSignal) when exitSignal is set. Without it, the process calls process.exit(130) instead of re-raising SIGINT, which breaks signal-chain conventions — parent processes (like shells) can't distinguish a numeric exit from a proper signal death.

Fix in Cursor Fix in Web

5 changes: 3 additions & 2 deletions apps/desktop/scripts/electron-launcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,12 @@ function buildMacLauncher(electronBinaryPath) {
return targetBinaryPath;
}

export function resolveElectronPath() {
export function resolveElectronPath(options = {}) {
const development = options.development === true;
const require = createRequire(import.meta.url);
const electronBinaryPath = require("electron");

if (process.platform !== "darwin") {
if (process.platform !== "darwin" || development) {
return electronBinaryPath;
}

Expand Down
Loading
Loading