14 KiB
14 KiB
TaskPing — 邮件回复写回 Claude Code CLI(执行手册)
目标读者:能执行脚本的 AI/自动化代理
运行环境:Linux/macOS(建议),Node.js ≥ 18,Claude Code 已安装并可在终端运行claude
1. 背景与目标
项目背景:用户在本机通过 Claude Code(CLI)进行编码。当前已实现:
- 通过 hooks 在任务结束/等待下一步时发出邮件提醒;
- 用户在手机上回复邮件;
- 本机有一个常驻“中继/Relay”进程轮询收件箱,能够拿到该回复。
当前卡点:把邮件正文写回到对应的 Claude Code CLI 会话,并回车执行。
本文档目的:为 AI/自动化代理提供两种可执行的落地方案:
- 子进程/PTY 模式:用
node-pty驱动一个 Claude Code 子进程,直接向伪终端写入并回车; - tmux 模式:让 Claude Code 运行在指定
tmuxpane,收到邮件后用tmux send-keys注入文本并回车。
两种方案都要求:
- 能路由到正确的 Claude 会话;
- 能清洗邮件正文,仅保留要注入的命令;
- 有安全与幂等控制;
- 有可观测性(日志/Tracing)。
2. 全局约束与安全要求
- 会话识别:优先按
In-Reply-To的Message-ID匹配;回退使用Subject中的 token(形如[TaskPing #ABC123])。 - 白名单:仅允许来自配置的发件人域/邮箱(通过 SPF/DKIM/DMARC 验证后再放行)。
- 正文提取:只取最新回复;去除历史引用、签名、HTML 标签、图片占位。支持三种输入:
- 纯文本一行命令;
- 代码块 ``` 包起来的命令(优先级最高);
- 主题/首行以
CMD:前缀标识。
- 幂等:用
Message-ID或gmailThreadId去重(重复投递不再次注入)。 - 限流:同一会话每分钟最多 1 条;单条长度上限(例如 8KB)。
- 日志:记录 token、会话、pane/pty id、注入摘要(前 120 字符),屏蔽隐私。
3. 公共依赖与配置
3.1 依赖(Node.js)
npm i imapflow mailparser node-pty pino dotenv execa
# tmux 方案需要系统安装 tmux:macOS: brew install tmux;Debian/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-ptyspawn('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 创建/记录一个
tmuxsession 与 pane:session = 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 执行)
- 路由正确性:构造两个 token(A、B),分别回复不同命令,验证注入不串台。
- HTML 邮件:从 iOS/Android 客户端发送富文本,确认能抽取纯文本命令。
- 代码块优先:正文包含代码块和普通文本时,优先选用代码块内容。
- 幂等:同一封邮件重复投递三次,仅执行一次。
- 过期处理:将
expiresAt回退到过去,验证拒绝注入。 - 白名单:非授权邮箱发送,日志报警且不注入。
- 超长命令:超过 8KB 截断并拒绝,返回提示。
8. 运行与守护
- 建议用
pm2/systemd管理relay-pty.js或relay-tmux.js; - 每 20 分钟向 IMAP 发送 NOOP(ImapFlow monitor 已处理),断线自动重连;
- 定时清理过期会话与孤儿
tmuxsession / PTY。
9. 故障排查
- 注入后无响应:检查会话路由是否匹配(token、pane/pty);确认 Claude 是否在等待输入的状态。
- 乱码/截断:调大 pty 列数;tmux 保证 pane 有焦点且无行编辑冲突。
- 重复执行:确认以
Message-ID去重;避免多客户端造成“已读后又转发”。 - 权限问题:
tmux由同一用户启动;Node 进程需与tmux同一用户。
10. 清单(AI 请按序执行)
- 安装依赖与
tmux(如需)。 - 写入
.env(IMAP/白名单/模式)。 - 准备并加载
SESSION_MAP_PATH(由发送提醒侧写入)。 - 选择 INJECTION_MODE=pty 或 tmux,启动对应
relay-*.js。 - 发送带
[TaskPing #TOKEN]的提醒邮件,收到后回复命令进行回归测试。 - 通过测试清单后,接入 hooks,开启常驻与告警监控。