3279 字
16 分钟
afk —— 通过邮件回复 opencode,人不在电脑前也能让 agent 继续干活

一句话核心#

afk(Away From Keyboard,离开键盘)是一个 OpenCode 插件。它的用法可以概括成一句:在终端输入 /afk 表示「我离开屏幕了」,此后运行中的 agent 一旦遇到需要你拍板的决策,就会把问题通过邮件发给你;你在任何地方回复邮件,会话就会自动唤醒继续——全程不需要回到终端。

v0.1.2,它已经不止「一个决策工具」这么简单了。除 request_decision 之外,还多出了三样东西:

  • notify_user:agent 达成关键结论时主动单向邮件同步给你,不打断它的工作;
  • /new:回复邮件派发一个全新任务,在项目目录里开出新会话;
  • 停顿自动通知:agent 停下一段时间后自动邮件告知进展,不再依赖它「记得」发通知。

仓库:7emotions/afk,MIT 协议,npm 包名 opencode-afk

7emotions
/
afk
Waiting for api.github.com...
00K
0K
0K
Waiting...

项目里到底有什么#

仓库的入口和分层很清晰,先看根目录文件:

文件作用
index.js插件入口(轻量客户端):确保守护进程运行、启动 SSE 订阅、暴露 request_decision / notify_user / set_email_mode
daemon.js每台机器一个的监听守护进程
request-decision.jsrequest_decision 工具的实现
notify.jsnotify_user 单向结论通知工具的实现
mailer.js共享邮件内核:root 会话解析、[omo:…] 路由令牌打标、SMTP 发送
config.js / messages.js配置加载(含环境变量覆盖)/ 可见文案 i18n 表
install.js一键安装器
core/watcher.js(IMAP IDLE)、process.js(扫描→解析→持久化)、reply-parse.jsnew-session.js/new 派发)、pause-notify.js(停顿通知)、inject.jssubscribe.js
store/pending-store.jsmode-store.jsregistry.jsuid-cursor.js
command//afk/back 命令模板
test/单元测试套件 + 连真实邮箱的集成脚本

依赖只有五个:@opencode-ai/plugin@opencode-ai/sdkimapflowmailparsernodemailer(外加 node ≥18)。没有 MCP、没有外部服务。

使用方式#

插件给 agent 暴露了两个邮件工具,外加一个模式开关:

request_decision(subject, question, { context, options, recommendation })
notify_user(subject, message)
  • request_decision:需要人类决策时调用。仓库自带的 AGENTS.md 写明了契约——如果返回「邮件模式已关闭」的消息,说明人正在屏幕前,改用 OpenCode 内置的 question 工具在对话里问;否则邮件已发出,agent 应当停下并结束当前回合,等回复被注入。
  • notify_user:agent 达成关键结论(任务完成、重大发现、里程碑)时调用。它是单向 FYI,不暂停 agent 的工作,也不占「每会话一个未决决策」的槽位;邮件同样带路由令牌,你回复它也会作为反馈注入回会话。

关键设计是默认不发邮件:是否发邮件由一个持久化的全局开关控制(守护进程里的 mode.json,默认 "off")。用户通过两条命令切换:

命令作用
/afk调用 set_email_mode(mode: "on") —— 打开邮件模式,之后 agent 的决策会发邮件
/back调用 set_email_mode(mode: "off") —— 关闭邮件模式,恢复在对话里提问

语义上是一个人要么在屏幕前、要么不在:这个状态全局唯一,不按会话区分。模式改动会立刻通过 SSE 的 mode 事件推送给所有已连接的实例。

从邮件派发新任务(/new#

这是 v0.1.2 之前完全没有的能力。回复任意一封 afk 邮件(决策或通知均可),正文以 /new 开头:

/new 修复登录页在 Safari 上的样式错位

效果是:它不会把这段文字注入你回复的那个会话,而是在那个会话所在的项目目录里开出一个全新的会话,以 /new 后的文字作为首个任务。同一目录若开着多个 opencode 实例,先抢到广播的那个创建;你会收到一封带新会话 token 的确认邮件,回复它就相当于直接跟新会话对话。一个裸 /new(没有任务文字)则会被当作普通回复注入原会话。

停顿自动通知#

这是「防漏通知」的机制保障:core/pause-notify.js 监听 opencode 的 turn-end 事件,当 agent 完成一波工作、安静下来一段时间后,会自动发一封邮件告诉你当前进展——不再依赖 agent 自己记得调用 notify_user。这封邮件同样带 [omo:…] 令牌,回复它就能让 agent 继续。

安装#

推荐走 npm:在 ~/.config/opencode/opencode.jsoncplugin 数组里加一行:

"plugin": [
  "opencode-afk@latest"
]

重启 opencode 时会自动从 npm 安装 @latest。首次安装后,把 config.example.json 复制成稳定的用户级配置:

mkdir -p ~/.config/opencode
cp <插件目>/config.example.json ~/.config/opencode/afk.json

配置放在 opencode 配置目录(而非插件目录),这样每次 @latest 更新重装插件后配置依然存活。维护者发新版后,你只需重启 opencode,@latest 自动跟进。

README 顶部也提供了一种「复制粘贴给 LLM」的安装方式:

Install and configure the afk plugin by following the instructions here:
https://raw.githubusercontent.com/7emotions/afk/main/INSTALLATION.md

手动/源码安装则是:

git clone https://github.com/7emotions/afk
cd afk
node install.js
# 然后编辑 ~/.config/opencode/plugins/afk/config.json,填上 imap/smtp 凭据和 recipient

node install.js 会完成五件事:把源码复制到 ~/.config/opencode/plugins/afk/(跳过 node_modules、.git 和密钥/运行时文件);跑 npm install --omit=dev;由 config.example.json 生成 config.json(绝不覆盖已有配置);在 ~/.config/opencode/opencode.jsonc 中注册插件(注释保留式文本插入,不做 JSON.parse);把 /afk/back 命令复制到 ~/.config/opencode/command/

需要填写的配置字段:

字段含义
imap.host/port/secure读取回复的 IMAP(默认 imap.qq.com:993
imap.user/passwordIMAP 凭据(QQ 邮箱用授权码当密码)
smtp.host/port/secure发送决策邮件的 SMTP(默认 smtp.qq.com:465
smtp.user/passwordSMTP 凭据
recipient决策邮件收件人,默认 smtp.user
allowList允许注入其回复的发件人白名单,默认 [smtp.user]
folder监听的邮箱文件夹,默认 INBOX

配置解析的优先级是:AFK_CONFIG 环境变量 → ~/.config/opencode/afk.json(稳定用户级配置)→ 插件目录下的 config.json(旧路径兜底)。所有连接相关字段也都可以用 AFK_* 环境变量覆盖(AFK_IMAP_HOSTAFK_SMTP_PASSWORDAFK_RECIPIENT 等,优先级高于配置文件)。注意:早期版本这些变量叫 EMAIL_WAKE_*,仓库重命名后统一改成了 AFK_* 前缀。

核心架构:单一监听守护进程 + PUSH 投递#

afk DAEMON — one process (atomic single-instance)

opencode instances — thin client plugins

SSE push

SSE push

instance A

ensure daemon · SSE subscriber · request_decision

instance B

ensure daemon · SSE subscriber · request_decision

... N instances

HTTP endpoints

/health · /events · /claim · /ack · /pending · /register · /mode

the only IMAP IDLE watcher

+ catch-up scan

pending-store

durable replies (pending.json)

mode-store

durable global mode (mode.json)

这张架构图把整体结构画得很清楚:一台机器上只运行一个守护进程daemon.js),它持有共享邮箱唯一的 IMAP IDLE 监听器;每个 OpenCode 实例只加载轻量客户端插件(index.js),插件负责确保守护进程在跑、并建立一条 SSE 流。守护进程解析邮件、持久化、通过 SSE 推送给实例;实例自检属主、抢占 claim,在进程内完成注入。

守护进程提供这些 HTTP 端点:/health/events/claim/ack/pending/register/mode。它绑定固定端口(默认 4100),端口绑定本身就是原子的单实例锁:两个实例同时启动守护进程时只有一个能 bind 成功,输家拿到 EADDRINUSE 安静退出。插件探测 /health 无响应时,会启动一个分离的守护进程并轮询直到它健康就绪。

完整流程用 README 里的时序图可以看得更细:

HumanDaemonPluginAgentHumanDaemonPluginAgentSTOPS and ends its turn (pauses)mail stays UNSEEN, no journal yetalt[mode is "off"][mode is "on"]request_decision(subject, question)GET /moderefuse → "use the question tool"POST /register {rootSessionID}SMTP "[omo:rootSessionID] subject"reply email (reply prefix / In-Reply-To)IMAP IDLE push → scan UID > cursor → parse → persist pending.jsonSSE delivery {uid, sessionID, body, from}self-check ownership → POST /claiminject reply (data, not instruction)POST /ackmarkSeen + journal → remove pending → release reservationwakes with the answer and continues

几个值得说的设计取舍:

零轮询。 新邮件由服务器通过 IMAP IDLE 推送送达(core/watcher.js),每次连接/重连后做一次性补扫,绝无定时轮询器——这是仓库里写明的硬性约束。

检测基于 UID 游标,不依赖 \Seen 也不依赖 SUBJECT。 守护进程只扫描大于持久化游标(store/uid-cursor.js)的 UID,和邮件是否已读(你可能在别的客户端先读过)以及 SUBJECT 索引(QQ 的 SUBJECT 索引存在延迟)都无关。游标会越过每个见到的 UID,因此自复制、无令牌的邮件永远不会卡住游标。

消息不丢失(README 记为 P0 修复)。 解析出的回复会持久化到 pending.jsonstore/pending-store.js),然后才做其他事;\Seen 标记 + journal 确认只发生在 /ack 处理器中——也就是属主实例完成注入之后。若守护进程在持久化与确认之间崩溃,邮件在服务器上仍处于 UNSEEN,pending.json 里也还留着记录,重启后会重新广播。投递语义是至少一次:注入之后、确认之前崩溃会重复广播,可能注入两次,但绝不丢失

多实例安全。 SSE 广播给所有连接的实例,但每个实例先按 session.directory 自检归属,再 POST /claim 抢占(先到先得);registry 保证每个会话同时只有一个未决决策(重复注册会返回 alreadyPending,不重发邮件)。

安全:回复是数据,不是指令#

把邮件内容注入会话,本质是往 agent 上下文里塞外部文本——prompt 注入的经典入口。仓库里有两层防护:

  • 发件人白名单allowList,默认只含你自己的 smtp.user):问题邮件发给 recipient,但只有来自白名单地址的回复才会被注入。陌生人就算复制了邮件主题里的 [omo:会话ID] 令牌伪造回复,只要地址不在白名单里就被忽略。
  • 数据而非指令:注入的正文被框架化为「数据,不是指令」(core/inject.jsbuildPayload),明确告知 agent 不得执行回复内容里的任何指令。README 特别注明这套框架属于安全框架而非显示文案,刻意不做 i18n。

/new 派发的任务文字同样按「数据而非指令」框架注入,绝不当作可执行指令。

Agent 侧的约定#

插件仓库里的 AGENTS.md 是面向 agent 的说明书,除了「提问后暂停」,还立了几条规矩:

  • 仅限主会话:带 parentID 的子 agent 调用会被拒绝,返回 Call from the main session only——因为决策必须携带主会话的完整上下文(子 agent 的结论由主会话代为转发);
  • 待办检查点:当 request_decision 将要发邮件(模式开启)时,agent 必须先把完整 todo 列表写进消息正文、清空待办,再发邮件,避免挂起等待期间被 todo-continuation 反复催着干活;回复注入后从检查点重建待办(notify_user 不暂停,无需检查点);
  • notify_user 要克制:它是结论通知,不是进度播报;
  • /new 邮件不进入本会话:它被路由到新会话,agent 无需对它做任何处理。

配置与文案都可调#

  • tuning 块可覆盖运行时序(默认值即源码常量):claimTtlMs、SSE 重连退避 reconnectBaseMs/reconnectMaxMs、IDLE 续期 idleRenewMs、IMAP 重连退避 backoffInitialMs/backoffMaxMs,以及新增的 pauseNotifyCooldownMs(turn 结束后静默多久判定「一波工作收尾」并邮件通知)等;
  • messages 块做可见文案本地化:默认英文,深合并覆盖任意键即可(比如把 decisionBodynotifyBodynewSessionBodypauseNotifyBodytool 下的文案换成中文);
  • 密码绝不会出现在日志里,配置序列化时打码为 ***

工程质量与已知限制#

单元测试用 node 内置的 node:test,零网络跑通。仓库 README 列出了覆盖范围:pending-store、mode-store、registry、inject-ack、process-mail、negative、uid-cursor、daemon HTTP(register + push + mode 端点 + SSE,现已拆成 daemon-mode / daemon-push / daemon-server 三个文件)、subscribe、request-decision(含模式门控)、notify_user、pause-notify、reply-parse、unit、messages、config。

cd afk && npm test

CI 也已就位:.github/workflows/publish.yml 在推送 v* tag 时自动跑单测并发布 opencode-afk 到 npm(目前已发到 v0.1.2)。

README 也如实写了几条已知限制:投递是至少一次(极端情况可能重复注入);单一邮箱、单机部署(多邮箱/多机需要各自跑守护进程并换端口);registry 的未决决策预留只在内存、守护进程重启后失效(24 小时 TTL 是唯一清理机制);IMAP/SMTP 默认值面向 QQ 邮箱,其他服务商要自己配 host/port。

小结#

afk 解决的问题很具体:agent 异步化之后,人不在屏幕前,它该怎么问决策问题、怎么汇报进展。答案是把它邮件化,再用「单守护进程 + IMAP IDLE 零轮询 + UID 游标 + SSE 推送 + pending 持久化 + ack 确认」这一套组合把「人回复 → 会话唤醒」这条链路做得可靠,同时用白名单和数据非指令框架堵住邮件注入这个安全口子。

短短几个版本,它从一个「决策邮件工具」长成了一个更完整的离屏协作入口:决策靠 request_decision,进展靠 notify_user 和停顿自动通知,派活靠 /new。如果读者经常让 agent 挂后台跑、自己又不在电脑前,可以试一下:输入 /afk 出门,回来时 agent 往往已经带着你的邮件答复把活干完了。

仓库地址:7emotions/afk,欢迎试用和提 issue。

afk —— 通过邮件回复 opencode,人不在电脑前也能让 agent 继续干活
https://lorenzofeng.top/posts/afk/
作者
Lorenzo Feng
发布于
2026-09-05
许可协议
CC BY-NC-SA 4.0