Skip to content
Merged
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
11 changes: 11 additions & 0 deletions apps/x/ANALYTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,17 @@ All renderer events live in `apps/renderer/src/lib/analytics.ts` (typed wrappers
- `settings_tab_changed` — `{ tab }`
- `onboarding_completed` — the onboarding flow finished (`App.tsx`)

### Mobile app events

Captured by the iOS app (`apps/mobile/src/lib/analytics.ts`, typed wrappers like the renderer's). Every event carries `platform: 'mobile'`, the counterpart of desktop's `platform: 'desktop'`, so the shared project separates surfaces. The key is injected at build time via `EXPO_PUBLIC_POSTHOG_KEY` (`EXPO_PUBLIC_POSTHOG_HOST` optional); without it every call is a no-op — dev builds send nothing.

- `mobile_paired` — `{ method: 'qr' | 'manual' | 'dev-link' }` — pairing with a rowboat-server succeeded
- `mobile_unpaired` — `{ reason: 'user' | 'unauthorized' }` — `unauthorized` = the server key was rotated out from under the phone
- `mobile_message_sent` — a chat message sent from the phone
- `mobile_reconnected` — the WS feed recovered after a disconnect
- `mobile_note_opened` — a note opened in the read-only browser
- `mobile_voice_used` — reserved; fires once voice ships in the dev build

## Person properties

Persistent across sessions for the same user. Set via `posthog.people.set` or as the `properties` arg to `identify`.
Expand Down
8 changes: 8 additions & 0 deletions apps/x/apps/main/forge.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,14 @@ module.exports = {
stdio: 'inherit'
});

// Build server (TypeScript compilation) - depends on shared, core;
// main imports it for the hosted rowboat-server transport
console.log('Building server...');
execSync('pnpm run build', {
cwd: path.join(__dirname, '../server'),
stdio: 'inherit'
});

// Build renderer (Vite build) - depends on shared
console.log('Building renderer...');
execSync('pnpm run build', {
Expand Down
1 change: 1 addition & 0 deletions apps/x/apps/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@agentclientprotocol/claude-agent-acp": "^0.67.0",
"@agentclientprotocol/codex-acp": "^1.2.0",
"@x/core": "workspace:*",
"@x/server": "workspace:*",
"@x/shared": "workspace:*",
"agent-slack": "0.9.3",
"chokidar": "^4.0.3",
Expand Down
40 changes: 37 additions & 3 deletions apps/x/apps/main/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import { isDurableTurnEvent } from '@x/shared/dist/turns.js';
import type { ISessions, EmitterSessionBus } from '@x/core/dist/runtime/sessions/index.js';
import type { ITurnEventBus } from '@x/core/dist/runtime/turns/event-hub.js';
import container from '@x/core/dist/di/container.js';
import { forwardRpc, shouldForwardChannel } from './rpc-forwarder.js';
import { getPairingInfo, rotateKey as rotateServerKey, setLanEnabled as setServerLanEnabled } from './server-host.js';
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
import { getModelCatalog } from '@x/core/dist/models/catalog.js';
import { captureProviderConnected, captureProviderDisconnected } from '@x/core/dist/analytics/model-providers.js';
Expand Down Expand Up @@ -513,12 +515,16 @@ export function registerIpcHandlers(handlers: InvokeHandlers) {
InvokeChannels,
InvokeHandler<InvokeChannels>
][]) {
// Strangler-fig: channels migrated to rowboat-server cross localhost HTTP
// instead of calling their in-process handler (which stays in the map as
// the ROWBOAT_FORWARD_MIGRATED=0 kill switch).
const forwarded = shouldForwardChannel(channel);
ipcMain.handle(channel, async (event, rawArgs) => {
// Validate request payload
const args = ipc.validateRequest(channel, rawArgs);

// Call handler
const result = await handler(event, args);
// Call handler (or the migrated channel's HTTP twin)
const result = forwarded ? await forwardRpc(channel, args) : await handler(event, args);

// Validate response payload
return ipc.validateResponse(channel, result);
Expand Down Expand Up @@ -565,6 +571,19 @@ function emitKnowledgeCommitEvent(): void {
*/
function emitWorkspaceChangeEvent(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
broadcastToWindows('workspace:didChange', event);
for (const listener of workspaceChangeListeners) {
listener(event);
}
}

// Non-window consumers of workspace:didChange — today the rowboat-server WS
// hub, which relays it to paired phones.
const workspaceChangeListeners = new Set<(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>) => void>();
export function onWorkspaceChange(
listener: (event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>) => void,
): () => void {
workspaceChangeListeners.add(listener);
return () => workspaceChangeListeners.delete(listener);
}

/**
Expand Down Expand Up @@ -852,7 +871,9 @@ export function startCodeRunFeedWatcher(): void {
// sessions:list awaits this deferred; main.ts resolves it when the scan
// settles (success or failure, so the list never hangs).
let resolveSessionsIndexReady: () => void;
const sessionsIndexReady = new Promise<void>((resolve) => {
// Exported for the rowboat-server host, whose sessions:list handler shares
// this gate (main and the hosted transport run on the same core instance).
export const sessionsIndexReady = new Promise<void>((resolve) => {
resolveSessionsIndexReady = resolve;
});
export function markSessionsIndexReady(): void {
Expand Down Expand Up @@ -3176,6 +3197,19 @@ export function setupIpcHandlers() {
}
return { show: false, chatDays: settings.chatDays };
},
// Rowboat server (phone pairing) — client-local: answered by main, which
// hosts the transport.
'server:getPairingInfo': async () => {
return getPairingInfo();
},
'server:setLanEnabled': async (_event, args) => {
await setServerLanEnabled(args.enabled);
return { success: true };
},
'server:rotateKey': async () => {
await rotateServerKey();
return { success: true };
},
// Embedded browser handlers (WebContentsView + navigation)
...browserIpcHandlers,
});
Expand Down
11 changes: 11 additions & 0 deletions apps/x/apps/main/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { syncModelProviderPersonProperties } from "@x/core/dist/analytics/model-
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";

import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import { startServerHost, stopServerHost } from "./server-host.js";
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js";
import started from "electron-squirrel-startup";
Expand Down Expand Up @@ -693,6 +694,13 @@ app.whenReady().then(async () => {
startTurnEventsWatcher();
startCodeRunFeedWatcher();

// rowboat-server transport (phone/API clients + the strangler-fig channel
// forwarder), hosted in-process on this same core instance. Needs the
// session index gate above; must never block boot.
startServerHost().catch((error) => {
console.error('[server-host] failed to start rowboat-server:', error);
});

// Mobile channels (WhatsApp/Telegram bridge): needs the session index, so
// start after initialize(). Failures must never block boot.
startChannelsWatcher();
Expand Down Expand Up @@ -824,6 +832,9 @@ stopSkillsWatcher();
shutdownAppsServer().catch((error) => {
console.error('[Apps] Failed to shut down cleanly:', error);
});
stopServerHost().catch((error) => {
console.error('[server-host] Failed to shut down cleanly:', error);
});
shutdownAnalytics().catch((error) => {
console.error('[Analytics] Failed to flush on quit:', error);
});
Expand Down
46 changes: 46 additions & 0 deletions apps/x/apps/main/src/rpc-forwarder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { isRpcChannel } from '@x/server';
import { whenServerReady } from './server-host.js';

// Strangler-fig seam (RFC SERVER_CLIENT_SPEC.md Q4/Q15): channels that have
// migrated to rowboat-server are forwarded over real localhost HTTP instead
// of calling core in-process, so the network API is exercised by the desktop
// app on every keystroke — it cannot rot. Unmigrated channels are untouched.
//
// Forwarding is ON everywhere, packaged builds included — an HTTP path only
// dev traffic exercises is the API-rot trap Q2 exists to prevent.
// ROWBOAT_FORWARD_MIGRATED=0 is the emergency kill switch.

export function forwardingEnabled(): boolean {
const env = process.env.ROWBOAT_FORWARD_MIGRATED;
if (env !== undefined) {
return env !== '0' && env.toLowerCase() !== 'false';
}
return true;
}

export function shouldForwardChannel(channel: string): boolean {
return forwardingEnabled() && isRpcChannel(channel);
}

export async function forwardRpc(channel: string, args: unknown): Promise<unknown> {
const server = await whenServerReady();
const res = await fetch(`http://127.0.0.1:${server.port}/rpc/${channel}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${server.key}`,
},
body: JSON.stringify(args ?? null),
});
const body = (await res.json().catch(() => null)) as
| { error?: { code?: string; message?: string } }
| Record<string, unknown>
| null;
if (!res.ok) {
const message =
(body as { error?: { message?: string } } | null)?.error?.message ??
`rpc ${channel} failed with status ${res.status}`;
throw new Error(message);
}
return body;
}
101 changes: 101 additions & 0 deletions apps/x/apps/main/src/server-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import os from 'node:os';
import { app } from 'electron';
import {
buildPairingPayload,
createCoreEventSources,
createCoreRpcHandlers,
createRowboatServer,
loadServerConfig,
resolveWorkspacePath,
rotateServerKey,
saveServerConfig,
type RowboatServer,
} from '@x/server';
import { WorkDir } from '@x/core/dist/config/config.js';
import { onWorkspaceChange, sessionsIndexReady } from './ipc.js';

// Vertical-slice hosting: main runs the rowboat-server transport in-process
// on its single core instance, so external clients (the phone) and the
// renderer's forwarded channels share one session index, one turn event hub,
// one set of schedulers. When the full server/client split lands (RFC
// SERVER_CLIENT_SPEC.md Phase 1), main stops booting core and spawns the
// standalone entrypoint instead — this module then shrinks to lifecycle
// management and everything else survives unchanged.

let current: RowboatServer | null = null;
let ready: Promise<RowboatServer> | null = null;

async function launch(): Promise<RowboatServer> {
const server = await createRowboatServer({
workDir: WorkDir,
handlers: createCoreRpcHandlers({ sessionsIndexReady }),
events: {
...createCoreEventSources(),
// workspace:didChange is sourced from main's debounced chokidar watcher,
// not a core bus — pipe it into the hub alongside the window fan-out.
subscribeWorkspaceEvents: onWorkspaceChange,
},
resolveWorkspacePath,
serverVersion: app.getVersion(),
});
current = server;
console.log(`[server-host] rowboat-server on http://${server.host}:${server.port} (lan: ${server.lanEnabled})`);
return server;
}

export function startServerHost(): Promise<RowboatServer> {
if (!ready) {
ready = launch();
}
return ready;
}

/** Resolves once the transport is listening — the RPC forwarder awaits this. */
export function whenServerReady(): Promise<RowboatServer> {
return startServerHost();
}

export async function stopServerHost(): Promise<void> {
const server = current;
current = null;
ready = null;
await server?.close();
}

export async function getPairingInfo(): Promise<{
running: boolean;
name: string;
port: number | null;
lanEnabled: boolean;
urls: string[];
token: string | null;
}> {
if (!current) {
return { running: false, name: os.hostname(), port: null, lanEnabled: false, urls: [], token: null };
}
const payload = buildPairingPayload(current.port, current.lanEnabled, current.key);
return {
running: true,
name: payload.name,
port: current.port,
lanEnabled: current.lanEnabled,
urls: payload.urls,
token: current.key,
};
}

// Persists the toggle and rebinds the listener (127.0.0.1 ⇄ 0.0.0.0).
// Connected clients drop and reconnect — acceptable for a settings flip.
export async function setLanEnabled(enabled: boolean): Promise<void> {
const config = await loadServerConfig(WorkDir);
await saveServerConfig(WorkDir, { ...config, lanEnabled: enabled });
await stopServerHost();
await startServerHost();
}

/** Mints a new server key, revoking every paired client, then rebinds. */
export async function rotateKey(): Promise<void> {
await stopServerHost();
await rotateServerKey(WorkDir);
await startServerHost();
}
43 changes: 43 additions & 0 deletions apps/x/apps/mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files

# dependencies
node_modules/

# Expo
.expo/
dist/
web-build/
expo-env.d.ts

# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision

# Metro
.metro-health-check*

# debug
npm-debug.*
yarn-debug.*
yarn-error.*

# macOS
.DS_Store
*.pem

# local env files
.env*.local

# typescript
*.tsbuildinfo

example

# generated native folders
/ios
/android
3 changes: 3 additions & 0 deletions apps/x/apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Expo HAS CHANGED

Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
1 change: 1 addition & 0 deletions apps/x/apps/mobile/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
21 changes: 21 additions & 0 deletions apps/x/apps/mobile/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading