claude-code-remote-remake/relay-injection.md

14 KiB
Raw Blame History

TaskPing — 邮件回复写回 Claude Code CLI执行手册

目标读者:能执行脚本的 AI/自动化代理
运行环境Linux/macOS建议Node.js ≥ 18Claude Code 已安装并可在终端运行 claude


1. 背景与目标

项目背景:用户在本机通过 Claude CodeCLI进行编码。当前已实现

  • 通过 hooks 在任务结束/等待下一步时发出邮件提醒
  • 用户在手机上回复邮件
  • 本机有一个常驻“中继/Relay”进程轮询收件箱能够拿到该回复。

当前卡点把邮件正文写回到对应的 Claude Code CLI 会话,并回车执行

本文档目的:为 AI/自动化代理提供两种可执行的落地方案:

  1. 子进程/PTY 模式:用 node-pty 驱动一个 Claude Code 子进程,直接向伪终端写入并回车;
  2. tmux 模式:让 Claude Code 运行在指定 tmux pane收到邮件后用 tmux send-keys 注入文本并回车。

两种方案都要求:

  • 路由到正确的 Claude 会话;
  • 清洗邮件正文,仅保留要注入的命令;
  • 安全幂等控制;
  • 可观测性(日志/Tracing

2. 全局约束与安全要求

  • 会话识别:优先按 In-Reply-ToMessage-ID 匹配;回退使用 Subject 中的 token形如 [TaskPing #ABC123])。
  • 白名单:仅允许来自配置的发件人域/邮箱(通过 SPF/DKIM/DMARC 验证后再放行)。
  • 正文提取:只取最新回复去除历史引用、签名、HTML 标签、图片占位。支持三种输入:
    • 纯文本一行命令;
    • 代码块 ``` 包起来的命令(优先级最高);
    • 主题/首行以 CMD: 前缀标识。
  • 幂等:用 Message-IDgmailThreadId 去重(重复投递不再次注入)。
  • 限流:同一会话每分钟最多 1 条;单条长度上限(例如 8KB
  • 日志:记录 token、会话、pane/pty id、注入摘要前 120 字符),屏蔽隐私。

3. 公共依赖与配置

3.1 依赖Node.js

npm i imapflow mailparser node-pty pino dotenv execa
# tmux 方案需要系统安装 tmuxmacOS: brew install tmuxDebian/Ubuntu: apt-get install tmux

3.2 环境变量(.env

# 邮件接收
IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_SECURE=true
IMAP_USER=bot@example.com
IMAP_PASS=********

# 邮件路由安全
ALLOWED_SENDERS=jessy@example.com,panda@company.com

# 路由与会话存储JSON 文件路径)
SESSION_MAP_PATH=/var/lib/taskping/session-map.json

# 模式选择pty 或 tmux
INJECTION_MODE=pty

# tmux 方案可选:默认 session/pane 名称前缀
TMUX_SESSION_PREFIX=taskping

3.3 会话映射(SESSION_MAP_PATH

结构示例:

{
  "ABC123": {
    "type": "pty",
    "ptyId": "b4c1...",
    "createdAt": 1732672100,
    "expiresAt": 1732679300,
    "messageId": "<CA+abc@mail.example.com>",
    "imapUid": 12345
  },
  "XYZ789": {
    "type": "tmux",
    "session": "taskping-XYZ789",
    "pane": "taskping-XYZ789.0",
    "createdAt": 1732672200,
    "expiresAt": 1732679400,
    "messageId": "<CA+xyz@mail.example.com>"
  }
}

注:会话映射由发送提醒时创建(包含 token 与目标 CLI 会话信息)。


4. 方案一:子进程/PTY 模式(推荐)

4.1 适用场景

  • 由中继程序直接管理 Claude Code 生命周期;
  • 需要最稳定的注入通道(不依赖额外终端复用器)。

4.2 实现思路

  • node-pty spawn('claude', [...]) 启动 CLI
  • 将返回的 pty 与生成的 token 绑定,写入 SESSION_MAP_PATH
  • 收到邮件 → 路由 token → 清洗正文 → pty.write(cmd + '\r')
  • 监控 pty.onData,必要时截取摘要回传提醒(可选)。

4.3 关键代码骨架(relay-pty.js

import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import pino from 'pino';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { spawn as spawnPty } from 'node-pty';
import dotenv from 'dotenv';

dotenv.config();
const log = pino({ level: 'info' });
const SESS_PATH = process.env.SESSION_MAP_PATH;

function loadSessions() {
  if (!existsSync(SESS_PATH)) return {};
  return JSON.parse(readFileSync(SESS_PATH, 'utf8'));
}
function saveSessions(map) {
  writeFileSync(SESS_PATH, JSON.stringify(map, null, 2));
}

function normalizeAllowlist() {
  return (process.env.ALLOWED_SENDERS || '')
    .split(',')
    .map(s => s.trim().toLowerCase())
    .filter(Boolean);
}
const ALLOW = new Set(normalizeAllowlist());

function isAllowed(addressObj) {
  const list = []
    .concat(addressObj?.value || [])
    .map(a => (a.address || '').toLowerCase());
  return list.some(addr => ALLOW.has(addr));
}

function extractTokenFromSubject(subject = '') {
  const m = subject.match(/\[TaskPing\s+#([A-Za-z0-9_-]+)\]/);
  return m ? m[1] : null;
}

function stripReply(text = '') {
  // 优先识别 ``` 块
  const codeBlock = text.match(/```[\s\S]*?```/);
  if (codeBlock) {
    return codeBlock[0].replace(/```/g, '').trim();
  }
  // 取首行或以 CMD: 前缀
  const firstLine = text.split(/\r?\n/).find(l => l.trim().length > 0) || '';
  const cmdPrefix = firstLine.match(/^CMD:\s*(.+)$/i);
  const candidate = (cmdPrefix ? cmdPrefix[1] : firstLine).trim();

  // 去除引用与签名
  return candidate
    .replace(/^>.*$/gm, '')
    .replace(/^--\s+[\s\S]*$/m, '')
    .slice(0, 8192) // 长度限制
    .trim();
}

async function handleMailMessage(source) {
  const parsed = await simpleParser(source);
  if (!isAllowed(parsed.from)) {
    log.warn({ from: parsed.from?.text }, 'sender not allowed');
    return;
  }

  const subject = parsed.subject || '';
  const token = extractTokenFromSubject(subject);
  if (!token) {
    log.warn({ subject }, 'no token in subject');
    return;
  }

  const sessions = loadSessions();
  const sess = sessions[token];
  if (!sess || sess.expiresAt * 1000 < Date.now()) {
    log.warn({ token }, 'session not found or expired');
    return;
  }
  if (sess.type !== 'pty' || !sess.ptyId) {
    log.error({ token }, 'session is not pty type');
    return;
  }

  const cmd = stripReply(parsed.text || parsed.html || '');
  if (!cmd) {
    log.warn({ token }, 'empty command after strip');
    return;
  }

  // 取得 pty 实例:这里演示为“按需重建/保持单例”两种策略之一
  const pty = getOrRestorePty(sess);
  log.info({ token, cmd: cmd.slice(0, 120) }, 'inject command');
  pty.write(cmd + '\r');
}

const PTY_POOL = new Map();
function getOrRestorePty(sess) {
  if (PTY_POOL.has(sess.ptyId)) return PTY_POOL.get(sess.ptyId);

  // 如果需要重建,会话应当保存启动参数;这里演示简化为新起一个 claude
  const shell = 'claude'; // 或绝对路径
  const pty = spawnPty(shell, [], {
    name: 'xterm-color',
    cols: 120,
    rows: 32
  });
  PTY_POOL.set(sess.ptyId, pty);
  pty.onData(d => process.stdout.write(d)); // 可替换为日志/回传
  pty.onExit(() => PTY_POOL.delete(sess.ptyId));
  return pty;
}

async function startImap() {
  const client = new ImapFlow({
    host: process.env.IMAP_HOST,
    port: Number(process.env.IMAP_PORT || 993),
    secure: process.env.IMAP_SECURE === 'true',
    auth: { user: process.env.IMAP_USER, pass: process.env.IMAP_PASS }
  });
  await client.connect();
  const lock = await client.getMailboxLock('INBOX');
  try {
    for await (const msg of client.monitor()) {
      if (msg.type === 'exists') {
        const { uid } = msg;
        const { source } = await client.download('INBOX', uid);
        const chunks = [];
        for await (const c of source) chunks.push(c);
        await handleMailMessage(Buffer.concat(chunks));
      }
    }
  } finally {
    lock.release();
  }
}

if (process.env.INJECTION_MODE === 'pty') {
  startImap().catch(err => {
    console.error(err);
    process.exit(1);
  });
}

说明:生产化时,建议在发送提醒一侧创建会话并持久化 ptyId 与参数;这里演示了“按需复原”的简化路径。

4.4 启动

node relay-pty.js

4.5 验收清单

  • 未授权邮箱无法触发注入;
  • 合法邮件写入后Claude Code 能立即进入下一步;
  • 重复转发的同一邮件不会二次注入;
  • 过期会话拒绝注入;
  • 大段/HTML/带签名的邮件能正确抽取命令。

5. 方案二tmux 模式(简单稳妥)

5.1 适用场景

  • 你已经在 tmux 里运行 Claude Code
  • 希望由外部进程向指定 pane 注入按键,不改变现有启动流程。

5.2 实现思路

  • 为每个 token 创建/记录一个 tmux session 与 panesession = taskping-<token>
  • Claude Code 在该 pane 里运行;
  • 收到邮件 → 定位到 pane → tmux send-keys -t <pane> "<cmd>" Enter

5.3 管理脚本(tmux-utils.sh

#!/usr/bin/env bash
set -euo pipefail

SESSION="$1"      # 例如 taskping-ABC123
CMD="${2:-}"      # 可选:一次性注入命令

if ! tmux has-session -t "${SESSION}" 2>/dev/null; then
  tmux new-session -d -s "${SESSION}"
  tmux rename-window -t "${SESSION}:0" "claude"
  tmux send-keys -t "${SESSION}:0" "claude" C-m
  sleep 0.5
fi

if [ -n "${CMD}" ]; then
  tmux send-keys -t "${SESSION}:0" "${CMD}" C-m
fi

# 输出 pane 目标名,供上层程序记录
echo "${SESSION}.0"

5.4 注入程序(relay-tmux.js

import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import pino from 'pino';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import dotenv from 'dotenv';
import { execa } from 'execa';

dotenv.config();
const log = pino({ level: 'info' });
const SESS_PATH = process.env.SESSION_MAP_PATH;

function loadSessions() {
  if (!existsSync(SESS_PATH)) return {};
  return JSON.parse(readFileSync(SESS_PATH, 'utf8'));
}

function extractTokenFromSubject(subject = '') {
  const m = subject.match(/\[TaskPing\s+#([A-Za-z0-9_-]+)\]/);
  return m ? m[1] : null;
}

function stripReply(text = '') {
  const codeBlock = text.match(/```[\s\S]*?```/);
  if (codeBlock) return codeBlock[0].replace(/```/g, '').trim();
  const firstLine = text.split(/\r?\n/).find(l => l.trim().length > 0) || '';
  const cmdPrefix = firstLine.match(/^CMD:\s*(.+)$/i);
  return (cmdPrefix ? cmdPrefix[1] : firstLine).trim().slice(0, 8192);
}

async function injectToTmux(sessionName, cmd) {
  const utils = new URL('./tmux-utils.sh', import.meta.url).pathname;
  const { stdout } = await execa('bash', [utils, sessionName, cmd], { stdio: 'pipe' });
  return stdout.trim(); // 返回 pane 目标名
}

async function startImap() {
  const client = new ImapFlow({
    host: process.env.IMAP_HOST,
    port: Number(process.env.IMAP_PORT || 993),
    secure: process.env.IMAP_SECURE === 'true',
    auth: { user: process.env.IMAP_USER, pass: process.env.IMAP_PASS }
  });
  await client.connect();
  await client.mailboxOpen('INBOX');

  for await (const msg of client.monitor()) {
    if (msg.type !== 'exists') continue;
    const { uid } = msg;
    const { source } = await client.download('INBOX', uid);
    const chunks = [];
    for await (const c of source) chunks.push(c);
    const parsed = await simpleParser(Buffer.concat(chunks));

    const token = extractTokenFromSubject(parsed.subject || '');
    if (!token) continue;

    const cmd = stripReply(parsed.text || parsed.html || '');
    if (!cmd) continue;

    const sessionName = `${process.env.TMUX_SESSION_PREFIX || 'taskping'}-${token}`;
    const pane = await injectToTmux(sessionName, cmd);
    console.log(`[inject] ${sessionName} -> ${pane} :: ${cmd.slice(0, 120)}`);
  }
}

if (process.env.INJECTION_MODE === 'tmux') {
  startImap().catch(err => {
    console.error(err);
    process.exit(1);
  });
}

5.5 启动

chmod +x tmux-utils.sh
INJECTION_MODE=tmux node relay-tmux.js

5.6 验收清单

  • tmux 中能看到 Claude 进程始终在运行;
  • 邮件回复注入后,当前 pane 光标位置正确、命令无截断;
  • 会话名与 token 一一对应,过期会话自动清理;
  • tmux 重启或 SSH 断连后自动恢复。

6. 选择建议与权衡

维度 子进程/PTY tmux
复杂度
稳定性 高(直连伪终端) 高(依赖 tmux 自愈)
多会话并发 简单(多 PTY 简单(多 pane/session
恢复/重连 需自管 tmux 自带
可移植性 受 pty/Windows 影响 Linux/macOS 友好
外部可观测 需自行实现 tmux capture-pane 可用

建议:桌面或服务器长期常驻,优先 PTY 模式;已广泛使用 tmux 的团队,直接 tmux 模式


7. 测试用例AI 执行)

  1. 路由正确性:构造两个 tokenA、B分别回复不同命令验证注入不串台。
  2. HTML 邮件:从 iOS/Android 客户端发送富文本,确认能抽取纯文本命令。
  3. 代码块优先:正文包含代码块和普通文本时,优先选用代码块内容。
  4. 幂等:同一封邮件重复投递三次,仅执行一次。
  5. 过期处理:将 expiresAt 回退到过去,验证拒绝注入。
  6. 白名单:非授权邮箱发送,日志报警且不注入。
  7. 超长命令:超过 8KB 截断并拒绝,返回提示。

8. 运行与守护

  • 建议用 pm2/systemd 管理 relay-pty.jsrelay-tmux.js
  • 每 20 分钟向 IMAP 发送 NOOPImapFlow monitor 已处理),断线自动重连;
  • 定时清理过期会话与孤儿 tmux session / PTY。

9. 故障排查

  • 注入后无响应检查会话路由是否匹配token、pane/pty确认 Claude 是否在等待输入的状态。
  • 乱码/截断:调大 pty 列数tmux 保证 pane 有焦点且无行编辑冲突。
  • 重复执行:确认以 Message-ID 去重;避免多客户端造成“已读后又转发”。
  • 权限问题tmux 由同一用户启动Node 进程需与 tmux 同一用户。

10. 清单AI 请按序执行)

  1. 安装依赖与 tmux(如需)。
  2. 写入 .envIMAP/白名单/模式)。
  3. 准备并加载 SESSION_MAP_PATH(由发送提醒侧写入)。
  4. 选择 INJECTION_MODE=ptytmux,启动对应 relay-*.js
  5. 发送带 [TaskPing #TOKEN] 的提醒邮件,收到后回复命令进行回归测试。
  6. 通过测试清单后,接入 hooks开启常驻与告警监控。