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
140 changes: 140 additions & 0 deletions .github/workflows/relay-deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
name: Deploy Relay Server

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

on:
push:
branches:
- main
release:
types:
- created

env:
IMAGE: ghcr.io/${{ github.repository_owner }}/rxcode-relay
Comment on lines +1 to +16

jobs:
build:
name: Build Relay Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Log in to GitHub Container Registry
if: github.event_name == 'release'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
type=sha,prefix=sha-

# Push / pull-request: build only, then smoke-test the binary boots in
# single-node mode (no REDIS_URL) and serves /healthz. No image is
# pushed and no deploy happens — that is release-gated below.
- name: Build Docker image (test only)
if: github.event_name != 'release'
uses: docker/build-push-action@v7
with:
context: relay-server
push: false
load: true
tags: rxcode-relay:ci
cache-from: type=gha,scope=rxcode-relay
cache-to: type=gha,mode=max,scope=rxcode-relay

- name: Health check (single-node)
if: github.event_name != 'release'
run: |
docker run -d --name relay-test -p 8787:8787 rxcode-relay:ci
for i in $(seq 1 30); do
if curl -sf http://localhost:8787/healthz > /dev/null 2>&1; then
echo "Health check passed"
curl -s http://localhost:8787/healthz
docker rm -f relay-test
exit 0
fi
sleep 1
done
echo "Health check failed"
docker logs relay-test
docker rm -f relay-test
exit 1

- name: Build and push Docker image
if: github.event_name == 'release'
uses: docker/build-push-action@v7
with:
context: relay-server
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=rxcode-relay
cache-to: type=gha,mode=max,scope=rxcode-relay

deploy:
name: Deploy to Kubernetes
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'release'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Set up kubectl
uses: azure/setup-kubectl@v5
with:
version: "latest"

- name: Configure kubectl
run: |
mkdir -p ~/.kube
echo "${{ secrets.K8S_CONFIG_FILE_B64 }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config

- name: Verify kubectl connection
run: kubectl cluster-info

- name: Deploy with kustomize
run: kubectl kustomize relay-server/k8s/ | kubectl apply -f -

- name: Pin deployment to this release
run: |
VERSION="${{ github.ref_name }}"
kubectl set image deployment/rxcode-relay \
relay=${{ env.IMAGE }}:${VERSION} \
-n rxcode-relay

- name: Wait for rollout
run: kubectl rollout status deployment/rxcode-relay -n rxcode-relay --timeout=300s

- name: Verify deployment
run: |
echo "=== Pods ==="
kubectl get pods -n rxcode-relay
echo "=== Service ==="
kubectl get service -n rxcode-relay
echo "=== Ingress ==="
kubectl get ingress -n rxcode-relay
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ relay-server/rxcode-relay
relay-server/.env
relay-server/.env.*
relay-server/*.p8
relay-server/k8s/secrets.yaml
6 changes: 5 additions & 1 deletion Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ private struct CompactChatMessageBubble: View {
assistantText(text)
}
case .tool(let toolCall):
ToolResultView(toolCall: toolCall, isMessageStreaming: message.isStreaming)
if toolCall.name == "AskUserQuestion" {
AskUserQuestionView(toolCall: toolCall)
} else {
ToolResultView(toolCall: toolCall, isMessageStreaming: message.isStreaming)
}
case .transientTools(_, let tools):
ChatTransientToolSummaryView(tools: tools)
}
Expand Down
11 changes: 10 additions & 1 deletion RxCode/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3601,7 +3601,16 @@ final class AppState {

switch agentProvider {
case .claudeCode:
mcpClaudeConfigPath = await mcp.writeClaudeConfig(projectPath: cwd)
// Allocate a per-session IDE-MCP port so the Claude agent can call
// IDE-only tools — cross-project chat (`ide__send_to_thread`),
// thread history, running jobs, usage. The bridge is a perl
// one-liner Claude runs as the `rxcode-ide` MCP server child.
let idePort = await ideMCPServer.allocate(
sessionKey: sessionKey,
capabilities: AgentProvider.claudeCode.staticCapabilities
)
let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) }
mcpClaudeConfigPath = await mcp.writeClaudeConfig(projectPath: cwd, bridgeCommand: bridge)
case .codex:
mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd)
resolvedSendMode = registerMode
Expand Down
36 changes: 36 additions & 0 deletions RxCode/Services/ClaudeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,39 @@ actor ClaudeCodeServer {

// MARK: - Private Helpers

/// Extra system-prompt text appended via `--append-system-prompt`. Tells the
/// agent about the IDE-provided `rxcode-ide` MCP server, which lets it talk
/// to agents in other RxCode projects/threads and introspect editor state.
private static let ideToolsSystemPrompt = """
# RxCode IDE tools

You are running inside RxCode, a desktop IDE that hosts multiple projects, \
each with its own chat threads and agents. RxCode injects a local MCP \
server named `rxcode-ide`; its tools are exposed to you with the \
`mcp__rxcode-ide__` prefix. Use them to coordinate with other agents \
(multi-agent talk) and to read editor state:

- `mcp__rxcode-ide__ide__get_projects` — list every project registered in \
RxCode, so you can discover sibling projects to read or message.
- `mcp__rxcode-ide__ide__get_threads` — list or natural-language search \
chat threads across projects (returns AI summaries, and ranked snippets \
when a query is given).
- `mcp__rxcode-ide__ide__get_thread_messages` — fetch the message history \
of a specific thread by id.
- `mcp__rxcode-ide__ide__send_to_thread` — talk to another project's agent: \
send a prompt to an existing thread (`thread_id`) or start a new thread in \
a project (`project_id`). This triggers a real agent run that may consume \
tokens; it returns the other agent's reply.
- `mcp__rxcode-ide__ide__get_running_jobs` / `ide__get_job_output` — \
inspect run-profile jobs (dev servers, scripts) executing in the IDE.
- `mcp__rxcode-ide__ide__get_usage` — current rate-limit / token usage.

Reach for these when a task spans projects — e.g. checking what another \
project already did, or delegating a subtask to its agent — rather than \
guessing. Prefer reading threads first; only use `ide__send_to_thread` when \
you actually need another agent to act, since it costs tokens.
"""

/// Build arguments array for the CLI invocation.
///
/// The user prompt is NOT a CLI argument — it is written to stdin as a JSON
Expand Down Expand Up @@ -801,6 +834,9 @@ actor ClaudeCodeServer {

if let mcpConfigPath {
args += ["--strict-mcp-config", "--mcp-config", mcpConfigPath]
// The `rxcode-ide` MCP server is part of this config — tell the
// agent the IDE multi-agent / introspection tools exist.
args += ["--append-system-prompt", Self.ideToolsSystemPrompt]
}

if let sessionId {
Expand Down
19 changes: 17 additions & 2 deletions RxCode/Services/MCPService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,26 @@ actor MCPService {

// MARK: - Provider Config

func writeClaudeConfig(projectPath: String?) async -> String? {
/// Write the `--mcp-config` JSON handed to the Claude CLI. If
/// `bridgeCommand` is non-nil an extra `rxcode-ide` stdio server is added —
/// the local MCP polyfill / introspection server that exposes IDE-only
/// tools (cross-project chat, thread history, running jobs, usage).
func writeClaudeConfig(
projectPath: String?,
bridgeCommand: (command: String, args: [String])? = nil
) async -> String? {
do {
let records = try enabledRecords(projectPath: projectPath)
var servers = dictionaryForClaude(records)
if let bridge = bridgeCommand {
servers["rxcode-ide"] = [
"type": "stdio",
"command": bridge.command,
"args": bridge.args,
]
}
let data = try JSONSerialization.data(
withJSONObject: ["mcpServers": dictionaryForClaude(records)],
withJSONObject: ["mcpServers": servers],
options: [.prettyPrinted, .sortedKeys]
)
return try writeGeneratedConfig(data: data, filename: "claude-mcp.json")
Expand Down
72 changes: 72 additions & 0 deletions RxCode/Services/RelayPresetCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import Observation
import os.log

/// One hosted relay-server option offered in the Mobile settings tab.
///
/// The canonical list is published as `relays.json` on the RxCode website so
/// new deployments can be advertised without shipping an app update.
struct RelayPreset: Codable, Identifiable, Hashable, Sendable {
let id: String
let name: String
let url: String
var description: String?
var recommended: Bool?

/// The parsed relay URL, or `nil` if the published value is malformed.
var relayURL: URL? { URL(string: url) }
}

/// Loads the hosted relay catalog from `code.rxlab.app/relays.json`, falling
/// back to a bundled default so the picker still works offline.
@MainActor
@Observable
final class RelayPresetCatalog {

static let shared = RelayPresetCatalog()

private(set) var presets: [RelayPreset] = RelayPresetCatalog.bundledPresets
private(set) var isLoading = false

private let logger = Logger(subsystem: "com.idealapp.RxCode", category: "RelayPresets")
private static let catalogURL = URL(string: "https://code.rxlab.app/relays.json")!

/// Hardcoded fallback. Keep in sync with `website/public/relays.json`.
static let bundledPresets: [RelayPreset] = [
RelayPreset(
id: "rxlab-hosted",
name: "RxLab Hosted Relay",
url: "wss://relay.code.rxlab.app",
description: "Official end-to-end encrypted relay operated by RxLab. Recommended for most setups.",
recommended: true
)
]

/// Fetch the latest catalog. Keeps the current list on any failure.
func refresh() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
var request = URLRequest(url: Self.catalogURL)
request.cachePolicy = .reloadRevalidatingCacheData
request.timeoutInterval = 10
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
logger.warning("relay catalog fetch returned non-2xx response")
return
}
let catalog = try JSONDecoder().decode(RelayCatalog.self, from: data)
let valid = catalog.relays.filter { $0.relayURL != nil }
if !valid.isEmpty {
presets = valid
}
} catch {
logger.warning("relay catalog fetch failed: \(error.localizedDescription, privacy: .public)")
}
}
}

private struct RelayCatalog: Codable {
let relays: [RelayPreset]
}
Loading