返回列表

博客

从手机远程运行 Claude Code

Tactic Remote 如何将你的 Mac 变成始终在线的 AI 编程服务器,并将 iPhone 变成一流的控制面——包含真实的架构细节,而非营销话术。

发布于 标签: engineering / architecture / guide / deep-dive
博客

继续查看该栏目下的产品与工程更新。

浏览此分类

Claude Code 是一个基于终端的 AI 编程助手。它运行在你的 Mac 上,读取代码库、编写代码、运行测试并提交更改。问题在于:它工作时你必须坐在 Mac 前面。

Tactic Remote 就是为了打破这个限制而存在的。Mac 仍然是执行引擎,iPhone 则成为控制面。本文将详细介绍这一切是如何运作的——架构、协议、权衡取舍以及设置方法。没有含糊其辞。

TL;DR: Mac 上的 Node.js WebSocket 服务器通过 tmux 管理 Claude Code。iPhone 通过局域网或 Cloudflare Tunnel 连接,发送命令并接收流式终端输出。Claude 完成任务或需要输入时,推送通知会提醒你。

架构

四个组件,三条网络路径:

┌──────────────────────────────────────────────────────────┐
│                       Your Mac                           │
│                                                          │
│  ┌─────────────┐    WebSocket     ┌──────────────────┐   │
│  │  Node.js    │◄───(ws://)──────►│   tmux session   │   │
│  │  server     │    send-keys     │                  │   │
│  │  (port 8765)│    capture-pane  │  $ claude        │   │
│  └──────┬──────┘                  │  > Working on... │   │
│         │                         └──────────────────┘   │
│         │ HTTP POST /hook                                │
│         │ (Claude Code hooks)                            │
└─────────┼────────────────────────────────────────────────┘
          │
          │ ws:// (LAN) or wss:// (Cloudflare Tunnel)
          │
┌─────────┼─────────────────────────────┐
│  Your iPhone                          │
│         │                             │
│  ┌──────▼──────┐   ┌──────────────┐   │
│  │  WebSocket  │──►│  ANSI Parser │   │
│  │  Client     │   │  + Terminal  │   │
│  │             │◄──│    Buffer    │   │
│  └─────────────┘   └──────────────┘   │
│                                       │
│  Push notification when Claude        │
│  completes or needs input             │
└───────────────────────────────────────┘

Node.js 服务器仅有一个运行时依赖:npm 包 ws。其他一切——HTTP 服务器、认证、速率限制、tmux 管理、文件浏览——全部基于 Node.js 内置模块构建。这是刻意为之的。更少的依赖意味着更少的故障点、更低的供应链风险和更小的攻击面。

服务器如何控制 Claude Code

这一部分出乎大多数人的意料:服务器和 Claude Code 之间没有 API。服务器控制 Claude Code 的方式和你完全一样——在终端中输入。

Claude Code 运行在一个 tmux 会话中。服务器通过 tmux send-keys 发送按键,通过 tmux capture-pane 读取输出。这是终端自动化,不是 SDK 集成。

创建会话

当你从 iPhone 创建新会话时:

# 1. 在项目目录创建一个分离的 tmux 会话
tmux new-session -d -s my-project -c /Users/you/code/my-project
 
# 2. 设置较大的回滚缓冲区(默认 2000,我们用 50,000)
tmux set-option -t my-project history-limit 50000
 
# 3. 在会话中输入 "claude"
tmux send-keys -t my-project -l 'claude'
 
# 4. 按回车
tmux send-keys -t my-project C-m

send-keys 的 -l 参数至关重要——它告诉 tmux 按字面方式解释内容,防止特殊字符被当作 tmux 键名处理。

50,000 行的回滚缓冲区是 tmux 默认值的 25 倍。Claude Code 会话会产生大量输出——编译日志、文件差异、测试套件。如果不加大缓冲区,几分钟的高强度工作后你就会丢失上下文。

向 Claude 发送消息

当你在 iPhone 上输入提示语并点击发送时:

# 清空已有输入
tmux send-keys -t my-project C-u
 
# 逐字输入内容
tmux send-keys -t my-project -l 'refactor the auth module to use JWT'
 
# 按回车
tmux send-keys -t my-project C-m

开头的 C-u 会清空输入行。如果没有它,当 Claude 在提示符中留有之前中断输入的残留文本时,你的消息就会追加到乱码后面。

停止 Claude

停止是一个多步骤序列,因为 Claude Code 有两种需要不同处理方式的状态:

# 如果 Claude 正在处理,则中断
tmux send-keys -t my-project C-c
sleep 0.2
 
# 再次中断(对顽固进程双击 Ctrl+C)
tmux send-keys -t my-project C-c
sleep 0.2
 
# 发送退出命令
tmux send-keys -t my-project -l '/exit'
tmux send-keys -t my-project C-m

如果 Claude 正在执行中,Ctrl+C 会中断它。如果 Claude 已经在提示符处,/exit 是 Claude Code 的退出命令。两者都发送可以覆盖两种状态。200ms 的延迟给 Claude 足够的时间在接收下一个信号前处理当前信号。

终端输出流

这是整个系统中最难的工程问题。Claude Code 在编译或测试运行期间以每秒数百行的速度输出文本。这些输出需要实时到达你的 iPhone,同时不能耗尽电池或压垮网络。

轮询循环

服务器使用 1 秒的轮询间隔捕获终端内容:

tmux capture-pane -t my-project -p -S -2000

这会抓取最近 2,000 行的回滚内容,以保留 ANSI 转义码的纯文本形式。

为什么用轮询而不是事件驱动?因为 tmux 不提供变更通知。我们评估过对 tmux 内部 socket 使用文件系统监控——在不同 macOS 版本间表现脆弱。1 秒的轮询间隔是一个好的平衡:足够快,让输出感觉是实时的;又足够慢,在会话空闲时最小化 CPU 使用。

稳定性检测

输出监视器不只是流式发送原始捕获内容。它运行了一个稳定性检测算法:

  1. 每秒捕获终端内容
  2. 与前一次捕获进行比较
  3. 如果不同:将更新发送到 iPhone,重置稳定性计数器
  4. 如果连续 5 次捕获相同(5 秒稳定):检查 Claude 是否在等待输入
  5. 如果 Claude 在提示符处:发送带有 final: true 的最终更新,然后停止轮询

这处理了 Claude 的渐进式渲染——在长输出期间,终端变化很快。Claude 完成后,屏幕趋于稳定。5 秒的稳定是 Claude 已完成的可靠信号。

监视器有 120 次检查(2 分钟)的硬性上限。如果 2 分钟后输出仍在变化,监视器停止运行,客户端可以请求手动刷新。

如何检测 Claude 的状态

服务器通过屏幕抓取 Claude Code 的 TUI 来判断它在做什么。这必然是启发式的:

const claudeIndicators = [
  '> ', // Input prompt
  '│ >', // Input prompt inside border
  '? for shortcuts', // Help text visible at prompt
  '╭─', // Claude UI top border
  '╰─', // Claude UI bottom border
  '●', // Running indicator
]

我们检查终端输出的最后几行是否包含这些模式。如果看到 Goodbye! 或 Session ended,说明 Claude 已退出。如果看到 shell 提示符(行尾的 $)但没有 Claude 特有的指示器,说明会话已退回到 shell。

这是否脆弱?有一定程度。如果 Claude Code 改变了 TUI 布局,我们的启发式规则就会失效。但替代方案——在 Claude Code 中构建自定义 IPC 通道——需要维护一个 fork 并跟踪上游变更。屏幕抓取现有 TUI 是务实的选择:它无需修改即可与每个版本的 Claude Code 配合使用。

ANSI 颜色保留

一个关键的设计决策:服务器保留输出中的 ANSI 转义码。它会剥离有问题的终端序列(光标移动、OSC 标题序列、私有模式变更),但刻意保留颜色码:

// 这些会被剥离(会导致渲染问题):
cleaned = cleaned
  .replace(/\x1b\].*?\x07/g, '') // OSC sequences
  .replace(/\x1b\[\?[0-9;]*[a-z]/g, '') // Private mode
  .replace(/\x1b\[[0-9;]*[ABCDEFGJKST]/g, '') // Cursor movement
 
// SGR 颜色码(\x1b[...m)不会被剥离

iOS 应用随后将这些代码解析为原生 AttributedString 样式。这让你在手机屏幕上看到 Claude Code 的完整彩色输出——语法高亮、diff 着色、状态指示器——都被忠实地渲染出来。解析器支持标准 16 色、256 色模式和 24 位真彩色。

WebSocket 协议

服务器在同一个 WebSocket 连接上同时使用两种协议——旧版扁平协议和新版请求/响应/事件协议。

新协议(推荐)

三种帧类型:

// 客户端 → 服务器:请求
{
  "type": "req",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "method": "send_to_claude",
  "params": { "content": "fix the failing tests", "session": "my-project" }
}
 
// 服务器 → 客户端:响应
{
  "type": "res",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "ok": true,
  "payload": { "status": "sent" }
}
 
// 服务器 → 客户端:事件(无请求 ID)
{
  "type": "event",
  "event": "hook_event",
  "payload": { "event": "Stop", "project": "my-project" }
}

id 字段实现了正确的异步请求多路复用。客户端可以同时发起多个请求,并正确匹配响应。旧版协议没有请求 ID——响应是按位置对应的,如果你快速发送两个请求,就无法分辨哪个响应属于哪个请求。

完整方法目录

方法用途
list_sessions获取所有 tmux 会话的名称、路径和连接状态
create_session在指定路径创建新的 tmux 会话
attach_session切换到现有会话,获取其当前输出
delete_session立即终止 tmux 会话
kill_session优雅地停止 Claude,然后终止会话
start_claude在当前会话中启动 Claude Code
stop_claude发送 Ctrl+C + /exit 序列
send_to_claude输入消息并按回车
send_key发送单个按键(Enter、Escape、Tab、y、n、Ctrl+C)
get_output获取当前终端内容和 Claude 的状态
get_full_output获取最多 5,000 行的回滚内容
change_path更改工作目录
list_directory浏览指定路径的文件
create_folder创建新目录

send_key 方法有一个明确的白名单,包含 18 个按键。你不能发送任意按键序列——只能发送与 Claude Code 交互时有意义的按键。y 和 n 键专门用于在手机上回应 Claude 的权限提示。

网络:LAN vs. Cloudflare Tunnel

两种连接模式解决两类不同的问题。

局域网(LAN)

你的 iPhone 通过 WiFi 直接连接到 Mac:

iPhone ──── ws://192.168.1.50:8765 ──── Mac

Mac 伴侣应用发布了一个 Bonjour 服务(_ws._tcp.),使你的 iPhone 可以自动发现局域网内的 Mac。无需配置——打开应用,点击横幅,即可连接。

延迟:往返低于 10ms。终端流式传输感觉是瞬时的。

局限:只能在同一网络中使用。走出家门就会断开连接。

Cloudflare Tunnel

对于远程访问,Mac 伴侣应用可以启动 Cloudflare Tunnel:

iPhone ── wss://xxx.trycloudflare.com ── Cloudflare Edge ── Mac

隧道是从 Mac 发起的纯出站连接。不需要在防火墙上开放端口。不需要路由器配置。Cloudflare 负责 TLS 终止、DDoS 防护和 WebSocket 代理。

快速隧道会生成一个随机的 *.trycloudflare.com 子域名和一个随机的 32 字符 API 密钥:

# 自动生成:openssl rand -hex 16
export CLAUDE_REMOTE_API_KEY="a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5"
 
cloudflared tunnel --url http://localhost:8765
# Your quick Tunnel has been created!
# https://abc123-random-words.trycloudflare.com

无需 Cloudflare 账号。免费。大约 10 秒即可启动。

延迟:通常 30–80ms,取决于你与最近 Cloudflare 边缘节点的距离。

权衡:每次重启隧道 URL 都会变。如需固定 URL,你需要一个 Cloudflare 账号和命名隧道(仍然免费)。

QR 码快速连接

手动设置连接意味着在手机键盘上输入 IP 地址、端口和 API 密钥。这既容易出错又很烦人。

Mac 伴侣应用生成一个包含连接详情的 JSON 格式 QR 码:

{
  "ip": "192.168.1.50",
  "port": 8765,
  "apiKey": "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5",
  "mode": "lan",
  "version": "1.0"
}

用 iPhone 应用通过 Vision 框架的条码检测器扫描即可。一次扫描就能完成整个连接设置——模式、地址、认证。大约 3 秒搞定。

通知管线

推送通知是 Tactic Remote"设好就忘"工作流的杀手级功能。启动一个长任务,起身离开,Claude 完成或需要你输入时收到通知。

工作原理

Claude Code 支持 hooks——在生命周期事件上执行的 shell 脚本。Mac 伴侣应用会自动配置这些 hooks,使其向本地服务器发送 POST 请求:

#!/bin/bash
# ~/.claude/hooks/stop.sh(自动配置)
curl -s -X POST http://localhost:8765/hook \
  -H "Content-Type: application/json" \
  -d "{\"event\": \"Stop\", \"project\": \"$CLAUDE_PROJECT\", \"tmux_session\": \"$TMUX_SESSION\"}"

当 Claude 完成任务时,此 hook 会触发。服务器接收 POST 请求,向所有已连接的客户端广播通知事件,iOS 应用通过 UNNotificationCenter 触发本地通知。

事件去重

Hook 事件可能会快速连续触发(例如,Claude 停止并重启多个子任务)。iOS 应用使用 3 秒时间窗口进行事件去重,以 event|project|session|timestamp 为键。如果相同的逻辑事件在 3 秒内重复到达,重复的事件会被静默丢弃。

前台 vs. 后台

当应用在前台时,通知事件会在终端缓冲区追加一个视觉指示器:

📢 [Claude] Project completed

当应用在后台时,触发系统通知。点击通知可直接跳回你的会话。

安全模型

远程运行 AI 生成的代码需要严肃的安全模型。以下是我们的方案。

认证

服务器通过两种机制支持 API 密钥认证:

  • Authorization: Bearer <key> 头部(标准方式)
  • Sec-WebSocket-Protocol: <key> 头部(为无法在升级请求中设置自定义头部的 iOS WebSocket 库提供的后备方案——这是常见的移动端限制)

认证在连接时检查一次。如果失败,WebSocket 会立即以代码 1008(策略违规)关闭。

命令注入防护

服务器执行的每个外部命令都通过 execSafe() 函数,该函数调用 child_process.spawn() 并设置 shell: false。参数以数组形式传递,从不拼接到字符串中。Shell 注入在结构上是不可能的。

输入验证提供纵深防御:

输入验证规则
会话名称/^[a-zA-Z0-9_-]{1,64}$/
Claude 命令/^claude(\s+--?[a-zA-Z0-9-]+)*$/
按键名称明确的 18 键白名单
文件路径解析为绝对路径,使用 startsWith() 检查

路径遍历防护

文件浏览被限制在用户主目录树内。无论其他设置如何,敏感目录始终被阻止访问:

const SENSITIVE_DIRS = [
  '.ssh',
  '.gnupg',
  '.aws',
  '.kube',
  '.config/gcloud',
  'Library/Keychains',
  'Library/Cookies',
  'Library/Application Support/1Password',
]

检查使用逐段匹配(而非子字符串匹配),因此名为 my-.ssh-backup 的目录不会触发误报。

速率限制

认证失败的尝试被限制为每个 IP 每分钟 30 次,使用内存中的滑动窗口实现。超过限制后,服务器返回 429 Too Many Requests 并附带 retryAfter 提示。

无云依赖

你的代码永远不会离开你的 Mac。Tactic Remote 是控制平面,而非数据平面。WebSocket 传输的是终端文本——命令输入和输出字符串。源代码文件、git 历史和构建产物都留在你的机器上。

使用 Cloudflare Tunnel 时,隧道对流量进行端到端加密。Cloudflare 看到的是加密的 WebSocket 帧,而不是你的代码。

iOS 应用:不是终端模拟器

开发早期,我们做了一个深思熟虑的决定:iPhone 应用不是一个笨重的终端模拟器。把完整的 80x24 终端塞进 4 英寸的手机屏幕是糟糕的体验。

相反,应用提供的是:

  • 对话式输入:在文本框中向 Claude 发送消息,而不是终端提示符
  • 终端输出视图:支持 ANSI 颜色的渲染文本,自动滚动,双指缩放字体大小(默认 8pt)
  • 快捷操作按钮:Enter、Escape、Tab、Y、N、Ctrl+C——与 Claude Code 交互时你真正需要的按键
  • 会话选择器:浏览和切换 tmux 会话
  • 路径浏览器:导航 Mac 的文件系统来设置工作目录
  • 斜杠命令:自动补全 /help、/clear、/compact、/cost、/model、/status

重连策略

移动应用所处的网络环境非常恶劣。你的手机会在 WiFi 和蜂窝网络间切换,进入电梯,进入睡眠。应用的重连策略处理了所有这些情况:

  1. 检测到连接断开:WebSocket 关闭事件触发
  2. 指数退避:在 min(attempts * 3, 15) 秒后重试,最多 5 次尝试
  3. 后台感知:如果应用进入后台,跳过重连。设置标志位,在回到前台时重连
  4. 会话恢复:重连后,列出会话 → 找到之前活跃的会话 → 重新连接 → 以 0.2s、0.7s 和 1.5s 的延迟轮询输出

重连后的错峰输出轮询(0.2s、0.7s、1.5s)处理了 tmux 在重新连接后需要一点时间才能产生捕获内容的情况。单次立即轮询往往会返回空内容。

终端缓冲区

TerminalBuffer 是一个环形缓冲区,上限为 500,000 字符和 10,000 行。溢出时,内容从前端截断,对齐到最近的换行符边界,这样你永远不会看到不完整的第一行。

这个上限存在的原因是 AttributedString 渲染在大规模时开销很大。500K 字符大约相当于 30 分钟的高强度 Claude Code 输出——足以覆盖任何合理的会话,同时保持 UI 的响应性。

Mac 伴侣应用

Mac 伴侣应用是一个菜单栏应用程序,负责管理 Node.js 服务器、Cloudflare 隧道和系统设置。

服务器打包

整个 Node.js 服务器打包在 Mac 应用内部。首次启动时,它将服务器文件复制到 ~/Library/Application Support/ClaudeCodeRemote/Server/。后续启动时,它逐字节比较文件,只重新复制已更改的文件。只有在 package.json 发生变化时才重新运行 npm install。

这意味着用户永远不需要打开终端来安装或更新服务器。

依赖检测

应用通过先扫描已知路径(/opt/homebrew/bin/、/usr/local/bin/),然后回退到 which 来检查所需工具(Node.js、tmux、Homebrew)。如果缺少依赖,设置界面会引导用户完成安装。

防止休眠

当服务器运行时,应用调用 IOPMAssertionCreateWithName 来阻止 macOS 进入睡眠。这是必要的——休眠的 Mac 无法提供 WebSocket 连接。

Hook 自动配置

伴侣应用将 shell 脚本写入 ~/.claude/hooks/,这些脚本会将生命周期事件通过 POST 发送到本地服务器。这在服务器启动时自动完成——不需要任何手动配置。

我们没有做的事

有些东西是我们刻意选择不做的:

应用内不做终端多路复用。 我们在服务端使用 tmux,但 iPhone 应用每次只显示一个会话。在手机屏幕上做分屏面板完全不可读。

不做文件编辑。 应用可以浏览文件但不能编辑。Claude Code 负责文件编辑——你是审阅者,不是编辑者。添加移动端代码编辑器是功能蔓延,会降低核心体验。

没有离线模式。 如果你的 Mac 不可达,应用会显示最后已知的状态,但无法做任何有用的事。我们不会装模作样。

不做远程桌面备选方案。 屏幕共享和 VNC 确实存在,但对于这个用例它们差了 10 倍。Tactic Remote 发送的是结构化文本,而非像素。这意味着更低的带宽、更低的延迟,以及为任务量身定制的 UI,而非缩小版的桌面。

快速开始

想跳过细节?以下是运行的最低配置。

Mac 端

  1. 安装 Mac 伴侣应用,下载地址 tacticremote.com/download
  2. 启动应用——它会自动安装依赖并启动服务器
  3. 记下 QR 码,它显示在应用中

iPhone 端

  1. 安装 Tactic Remote,从 App Store 获取
  2. 扫描 QR 码,来自 Mac 应用
  3. 创建会话,指向你的项目目录
  4. 启动 Claude 并发送你的第一条提示

对于远程访问(家庭网络之外),在 Mac 应用的设置中启用 Cloudflare Tunnel 开关。应用会自动生成公开 URL。

配置通知

在 Mac 应用中,hooks 在服务器启动时自动配置。在 iPhone 上,当系统提示时授予通知权限。就这些。

常见问题

关闭 iPhone 应用后 Claude 会继续运行吗?

会的。Claude 在你 Mac 上的 tmux 中运行。iPhone 应用只是那个会话的一个窗口。关闭应用,把手机放进口袋——Claude 继续工作。再次打开应用,你就会看到你不在时发生的一切。

网络断了怎么办?

iPhone 应用会自动重连,使用指数退避(在约 45 秒内最多尝试 5 次)。重连后,它会重新连接到之前的会话并获取累积的输出。不会丢失任何工作——tmux 在 Mac 端缓冲了一切。

耗电量如何?

iPhone 应用不会维持持久的后台连接。进入后台时,它会正常断开连接。不使用时对电池的影响基本为零。

可以用于其他 AI 编程工具吗?

Tactic Remote 是围绕 Claude Code 的终端界面构建的,但底层架构(tmux 会话管理、终端流式传输)适用于任何基于终端的工具。只要能在终端中运行,服务器就能管理它。状态检测的启发式规则是 Claude Code 特有的,但基本的 send-keys/capture-pane 循环是通用的。

我的代码安全吗?

你的代码永远不会离开你的 Mac。Tactic Remote 通过加密的 WebSocket 连接传输终端文本。服务器没有云组件、没有遥测、没有分析。使用 Cloudflare Tunnel 时,流量是端到端加密的——Cloudflare 代理的是加密帧,而非你的源代码。


如果你想深入了解特定子系统,请参阅为什么我们选择 tmux 作为会话骨架、构建实时终端流和安全模型文档。

立即体验 Tactic Remote

用手机远程控制编程 Agent

连接 Mac、Windows 或 Linux 电脑上的 Claude Code、Codex 等 Agent,在 iPhone 或 iPad 上查看进展并发送下一条要求。