让一个 agent 会话给另一个会话发消息,优先显示在接收方会话中,不需要主动查信箱。
支持 Claude Code、Codex CLI 与 Antigravity(agy)。agy 没有直投通道,靠 hook 注入; 已离线的 agy 会话会被 headless resume 唤醒,让它的 hook 自己把消息取走。
A 会话: xmsg send <B的session> "..." → 持久化、原子占位、尝试可见直投
B 还会再调工具: PreToolUse hook 触发 → 取出消息 → additionalContext
→ B 在这一轮的下一个工具调用之前就看到了内容
B 这一轮要收尾: Stop hook 触发 → 取出消息 → decision:block + reason
→ 这一轮带着消息重启,B 在转 idle 之前看到了内容
B 是 agy 会话: PreInvocation hook → injectSteps/ephemeralMessage(每轮推理前)
Stop hook → decision:continue + reason(收尾时带消息重启)
B 是离线 agy: send 发现没人持有它的 presence lock → detached 执行
`agy --conversation <id> -p <踢一脚> --print-timeout …` headless 唤醒
→ 被唤醒进程的 PreInvocation hook 照常认领队列里的消息
接收方不问「有没有新消息」:可见直投使用 host 的消息入口;不可用时才由 hook 注入上下文。
按接收端运行版本与实际能力选择。进程版本和已加载 thread 都需要核实,不能只检查 PATH 中的新 CLI。
| 窗口 | 触发时机 | 机制 | 模型怎么看到 |
|---|---|---|---|
uds-direct |
随时,包括对方 idle | 直连 Claude host 的 unix socket | host 自己起一轮来处理 |
codex-delegated-tool-output |
已加载的 Codex thread,运行中或 idle | turn/start.toolOutput 委派封套 |
可见同伴消息,保留工具权限层级 |
codex-turn-start / codex-turn-steer |
已加载 thread,分别 idle / active | app-server 可见输入 API | 正文标注同伴来源;不设置模型或权限 |
codex-queue |
随时,包括对方 idle | 官方 codex queue 命令 |
渲染成用户输入,起一轮处理 |
PreToolUse |
一轮正在跑,即将调工具 | hook | 拼进那次工具调用前的上下文 |
Stop |
一轮正要收尾、转 idle | hook | 带着消息重启这一轮 |
PreInvocation(agy) |
每轮模型推理前 | hook(事件名走命令行参数) | injectSteps[].ephemeralMessage |
Stop(agy) |
一轮正要收尾、转 idle | hook | decision:continue + reason 重启这一轮 |
agy-wake |
离线 agy 会话,send 时 | detached agy --conversation headless resume |
唤醒轮自己的 PreInvocation hook 注入;消息本体不上命令行 |
xmsg send 先写库,再原子占位,然后尝试直投。确定未送达才恢复 hook 队列。
写入后超时、ACK 丢失或占位后进程崩溃会保留 direct-uncertain / direct-inflight,
xmsg outbox 显示 UNKNOWN,不会自动双投递。操作人应先核对接收会话再决定是否重发。
accepted 只证明 API 接受或字节写入,不是模型已读确认。
以下为官方源码首次引入提交对应的首个稳定 tag,不是本机最早安装版本。
实际发送还要通过初始化、已加载 thread 查询、canAcceptDirectInput == true 等能力检查。
完整引入提交、相邻稳定版边界和复核命令见
消息接口版本兼容性证据。
| 功能 | 首个稳定版本 | 官方提交 |
|---|---|---|
app-server turn/start |
0.56.0 | 658255492 / #6216 |
thread/loaded/list |
0.80.0 | 5b7707dfb / #8902 |
turn/steer |
0.99.0 | 0d8b2b74c / #10821 |
thread/read.status |
0.105.0 | 1f54496c4 / #11786 |
| hooks SessionStart / Stop 引擎 | 0.114.0 | 244b2d53f |
| Stop hook 阻止结束并续跑 | 0.115.0 | 9a44a7e49 |
| shell PreToolUse hook | 0.117.0 | 73bbb07ba |
thread/turns/list |
0.122.0 | eaf78e43f / #17305 |
| Unix socket transport | 0.125.0 | 8a0ab3fc1;此时不能按 WebSocket 使用 |
| Unix socket 上的 WebSocket | 0.126.0 | 687c5d908 |
| managed app-server daemon | 0.131.0 | 0c8d42525 |
thread/read.canAcceptDirectInput |
0.145.0 | 3f0669dbd;需要 experimentalApi |
thread/list.canAcceptDirectInput |
0.147.0 | read 字段之后扩展到列表;xmsg 不依赖此列表字段 |
| thread queue APIs | 0.148.0 | 9341b3831 / #38456 |
codex queue CLI |
0.149.0 | 83d015375 / #39092 |
codex_tui.send_message_to_thread |
0.150.0 | a8468330b / #40308 |
turn/start.toolOutput 及 TUI 委派消息渲染 |
0.151.0 | e56e4922e / #41002、72c96598c / #41046 |
PreToolUse additionalContext |
0.129.0 | af86be529;不是所有旧 hook 引擎都支持上下文注入 |
来源:官方仓库、
app-server 文档。
当前实测环境为 CLI/daemon 0.156.1;版本常量在 codex_delivery.py,边界与降级测试在
tests/test_visible_delivery.py。预发行版不能只按数值假定稳定版接口已存在。
路由顺序:
- 已运行 daemon 的版本至少 0.145.0:连现有 Unix WebSocket,初始化并只读确认目标已加载且允许输入。
- daemon 与同一
CODEX_HOME下所有已识别的真实 Codex TUI 进程均至少 0.151.0,且发送者有真实 Codex 祖先进程、 session 身份匹配、源 thread 同样已加载:使用 Codex 已识别的codex_tui.send_message_to_thread委派封套,通过turn/start.toolOutput投递。这是复用官方协议,不是调用原生 MCP 工具;source_thread_id使用真实发送 thread,input仅传原始正文,XML 字段按协议转义。 来源由 Codex 原生Sent by展示,不再向正文叠加 xmsg 标签或信封。 - Claude、cron、未能核验的源身份或旧 TUI:可见输入正文明确标注来源。idle 用
turn/start, active 用最新一条 turn 的expectedTurnId调turn/steer;不恢复 thread、不覆盖模型/权限。 - 没有可用 daemon,且是独立 Codex host:运行进程版本至少 0.149.0,CLI
queue --help确实 宣告两个参数后,使用codex queue,输出明确写下一轮。共享 daemon PID 本身不能证明某 thread 活跃, 因而不能在 thread 查询失败后据此盲目排入官方队列。 - 确定未送达时回到 hook;任何消息写入结果不明都停止自动降级。
thread.cliVersion 是创建 thread 时的版本,不能据此判断当前 TUI。xmsg 只读扫描 /proc,
过滤 app-server、exec/queue、其它 CODEX_HOME,通过运行二进制的 --version 取版本并在单次发送内缓存。
因为没有可靠的 thread 到 TUI PID 映射,发现旧/未知客户端或没有客户端就保守使用可见输入路径,
不会启动、恢复、升级任何会话。扫描限时,无法完成也不假定支持。
Claude 当前验证版本为 2.1.281;每次直投读取目标运行二进制版本并检查 PID 启动时刻与活 socket。
cc-socks 是未公开接口,首次引入版本没有可靠边界,不能把公开跨会话功能的 2.1.224
误作内部 UDS 的最低版本(变更记录在 2.1.162 已出现相关修复)。因此版本用于诊断,活 socket
与原有严格 envelope 决定能否尝试;from-mode 只来自发送端自己的权限记录,绝不伪造。
2.1.247 起 Claude 消息默认折叠成一行,可用 Ctrl+O 展开。
Claude Code 的 host 进程在 /run/user/<uid>/cc-socks/<pid>.sock 上监听,并直接接受
inbound peer 消息。这条路完全不经过 hook —— host 自己读走那一行,然后主动起一个
turn 去处理。所以它能触达一个正停在提示符上、没有任何 turn 在飞的会话。
2026-08-31 实测(claude-opus-5[1m]):一个 idle 了 31 秒的会话收到消息后自己起了一轮, 零人工确认,并原样报出了探针码。
(Codex 优先走上面的可见 app-server API;独立旧会话保留 codex queue。)
这是 host 自己的通道,不是公开 API,所以实现全程按 best-effort 对待:socket 没了、 连接前失败,消息就留在队列里由 hook 投;已开始写入但结果不明则保留 UNKNOWN。直投只是队列之上的加速器, 永远不是队列的替代。
三道护栏,都在 direct_send 里:
- 不投给自己:按 pid 判定。会话读到自己的话当成同伴消息是最糟的失败模式。
- pid 复用防护:光记 pid 不够 —— 进程退出后 pid 会被复用,socket 文件甚至比
属主活得更久。所以连
/proc/<pid>/stat的启动时刻一起记,两者都对得上才投。 - 只对 Claude:Codex 不使用这个 socket,另有 WebSocket app-server 路径。
接收方对 inbound peer 消息有一道 ingress 闸门:当接收方自己是 bypass 权限模式、 而发送方没有声明自己的权限模式时,消息会被 hold 住,在对方终端上渲染成一个 「Deliver / Deny」的选择等人点。声明了且与接收方同级,才直接放行。
声明写在信封的 from-mode 属性里(取值只有 bypass / prompting)。xmsg 填这个值
只从发送会话自己上报过的 permission_mode 推导 —— 该字段就在 hook payload 里,
由接收方在注册时记进 peers,发送方无从指定。
发送方没有 peer 记录时(人在终端敲命令、cron 任务),就不带声明,消息按预期被 hold。 这是正确结果而不是待修的缺口:替发送方编一个声明等于伪造它的权限背书。
Codex 侧永远没有 peer 记录 —— 它不开 uds socket(见上一节「只对 Claude」),
permission_mode 从来没上报过 ⇒ xmsg 无从推导 from-mode ⇒ 发出的信封不带声明。
接收方若是 bypass 模式的 Claude 会话,每一条都会 hold。
这不是配错了,是两个前提叠加的必然结果。要区分三件事:
| 归属 | 能不能改 | |
|---|---|---|
Codex 不上报 permission_mode |
Codex CLI 没有这个概念 | 不能 |
| xmsg 不替它编一个声明 | 本工具的刻意设计(见上) | 不该 |
| 接收方 hold 未声明来源的消息 | Claude Code 的 ingress 闸门 | 见下 |
第三项在接收侧有个开关:Claude Code 的 crossSessionInbound 设置,
写在 ~/.claude/settings.json(或项目 .claude/settings.json),取值 accept / hold(默认)/ refuse,
也可以在会话里跑 /config 找 Messages from your other sessions 那行。
| 时点 | Codex 发来的消息 |
|---|---|
| 改配置前 | 每条都 hold 等确认 |
写入 "crossSessionInbound": "accept" 后,同一会话内 |
仍然 hold |
| 重启(WSL 重启 + 新会话)后 | 直接到达,不再 hold ✅ |
为什么会误判成「不需要重启」 —— 读二进制会看到策略是每条消息即时重算的:
function v9e(e){ return R(e, S(m(e))) } // 每条 peer 消息投递时调用 S()
// S() 第一步就读 crossSessionInbound,有显式设置就直接返回该策略,
// 不再走「权限模式是否匹配」那套判断⇒ 据此推出「下一条消息就该走 case "accept"」是错的,因为
即时重算的是策略判断,不是配置文件 —— 设置在会话启动时读进内存。
这两件事很容易混:S() 每次都调,但它读的是内存里那份已加载的设置。
⇒ 通用形状:「这个函数每次都被调用」不等于「它读的数据每次都重新加载」。
配置项名字可以自证(strings 扫 claude.exe 命中 crossSessionInbound,
旁边还有 crossSessionInboxRowVisible / crossSessionMessaging;
二进制里那条提示逐字写着 "The sender did not attest its permission mode and this session
bypasses prompts. Review it below, or set crossSessionInbound to accept" ——
正是这个场景)。
若重启后仍被 hold,代码里还有两个候选:
- settings 被判为无效值 —— 有个检查看
errors里有没有severity === "warning"的条目,对应提示:"A settings file has an unrecognizedcrossSessionInboundvalue (see the settings warning), so messages are held while it is present"。 ⇒ 自查:/config看 Messages from your other sessions 那行显示accept还是hold。 显示hold就是设置没被采纳(键写错层级、值拼错),不是「没生效」。 - kill-switch 优先于一切设置 —— 判定链第一步
if (!Ko()) return {policy:"refuse", refuseCause:"kill-switch"}。
两条还有两个已知限制,无论上面那条成不成立都适用:
- 仓库级设置只能收紧不能放宽 —— 某项目的
.claude/settings.json若设了hold, 全局的accept在那个项目里不生效(二进制原文:"a repo may only tighten, so your own 'accept' cannot override it")。组织的 managed settings 同理。 - 它降的是一道安全闸门,作用于该配置文件覆盖的所有会话,不只你当下这一个。
⇒ 现实可行的减负方式是让 Codex 少发、发大块,而不是关闸门。
反方向(Claude → Codex)不受影响:带上 XMSG_FROM_TOOL / XMSG_FROM_SESSION 就能正常署名投递
(漏了这两个环境变量则以 unattributed 送达,接收方被告知不要单凭它行动 —— 那是署名缺失,
与本节的 hold 是两件事)。
信封解析是严格的 —— host 会把解析结果重新渲染一遍跟原文比对,所以某个属性里出现
越界字符不只是那个属性失效,而是整个信封解析失败、消息退化成 unattributed。
envelope() 因此逐个属性校验,宁可丢掉一个属性,也不赌整个信封。
PreToolUse 只在这一轮还会再调工具时才有机会。一轮临近结束时没有下一次工具调用了,
Stop 就是最后一个还能触达模型的执行点。实测:真实会话里明确要求「不要调用任何工具」,
PreToolUse 一次都没触发,消息仍通过 Stop 投达。
Stop 这一路只能用 decision: block,不能用 additionalContext —— 此刻没有待发的
工具调用,additionalContext 无处可拼。block 是唯一能让内容进到模型眼前的输出,
代价是它以「重启这一轮」的方式实现。
stop_hook_active 守卫是承重的,不是防御性代码:被 block 重启的那一轮结束时
Stop 会再触发一次,此时 payload 里 stop_hook_active=true。不据此放行就是一个
永不终止的 block 循环。实测两次触发分别为 false / true,守卫生效。
该守卫不消费消息 —— 被抑制的那次不领取,消息留给重启后那一轮的首次工具调用。
一个真正 idle 的会话不执行任何 hook,所以在 hook 这一层确实没有第三个窗口:
- Claude Code 的 hook 事件表里没有 idle 事件。二进制里那三处
"Idle"是状态栏 文案(status:"Idle"、{word:"Idle",dim:!0}),不是 hook 事件。可用事件是PreToolUse/PostToolUse/UserPromptSubmit/SessionStart/SessionEnd/Stop/SubagentStart/SubagentStop/PreCompact/PostCompact/Notification/PermissionRequest—— 其中Stop是执行时点最靠后的那个。 - Claude 自带的
SendMessage契约原文也是 "messages enqueue and drain at the receiver's next tool round"。
codex queue。见开头的版本与能力选择。
只依赖 python3(标准库,无第三方包)与 host 自己的 hook 机制。
可选的 codex-history 需要 Python 3.11+、Codex CLI;fzf 仅用于增强选择体验。
git clone https://github.com/HermanShi/xmsg.git ~/agent-msg
ln -s ~/agent-msg/bin/xmsg ~/bin/xmsg # 或拷进任何 PATH 目录
ln -s ~/agent-msg/bin/codex-history ~/bin/codex-history
xmsg doctor # 建库 + 自检clone 到 ~/agent-msg 以外的路径也行,此时给两个 host 的 hook 配置和
XMSG_IMPL 指对位置即可(默认值回落到 $HOME/agent-msg/xmsg.py)。
最后按 hook-config.diff 手工加 hook 条目,启用接收端自动注册和直投失败后的降级接收。
可见直投本身不依赖 hook:Claude 的活 UDS 可以独立发现和接收;Codex 直投需要
已经核实的接收坐标及对应 app-server/queue 能力,不能把仅有历史 thread ID 当作可达。
没有 hook 的会话若无法直投,消息就只能等 TTL 到期,不会自动注入。
PreToolUse 和 Stop 两个条目指向同一个 xmsg-hook.sh,事件名从 payload 里读,
不靠参数区分。只加 PreToolUse 会缺少「对方正要 idle」时的 hook 降级窗口。
Antigravity(agy)是事件名规则的例外:它的 payload 不带事件名,必须由 hook
命令的参数传入,挂在 ~/.gemini/config/hooks.json(纯新增条目,不动已有 hook):
"xmsg": {
"PreInvocation": [
{ "type": "command", "command": "<路径>/xmsg-hook.sh agy PreInvocation", "timeout": 5 }
],
"Stop": [
{ "type": "command", "command": "<路径>/xmsg-hook.sh agy Stop", "timeout": 5 }
]
}漏传事件参数时 hook 会静默不认领消息(消息留在队列等正确的事件),不会以 agy
读不懂的输出形态浪费掉一次投递。agy 的 payload 字段是 camelCase
(conversationId / workspacePaths),xmsg 内部自动归一化。没有直投通道,
send 到 agy 一律 queued:运行中的会话由对方下一次 PreInvocation 或 Stop
(decision:continue 带着消息重启该轮)投递;离线会话则在 send 时派生一个
detached 的 agy --conversation <id> -p <中性踢一脚> --print-timeout <dur> 做
headless resume(2026-10-02 PONG 实测打通)。消息本体不上命令行:被唤醒轮的
PreInvocation hook 照常认领队列,所以唤醒失败或半路死掉都无损——消息仍在队列里
等 TTL 或下一次唤醒。
判活走 presence lock:~/.gemini/antigravity-cli/presence/<conversationId>.lock
在会话attach 过一次后就会留下(过期锁永不清理,几个月前的也在),只有运行中的
进程持有该锁的 fd 才算活。活会话绝不唤醒,避免和已 attach 的进程抢锁。agy 的
language server 端口(HTTPS gRPC + HTTP)没有可用的消息路由,remote-control
daemon 默认关闭,所以 resume 是唯一可靠的到达离线会话的通道。
唤醒调节项(均有默认值,可不配):XMSG_AGY_BIN(agy 二进制,默认 PATH →
~/.local/bin/agy)、XMSG_AGY_WAKE_TIMEOUT(Go duration,默认 600s,
裸数字会自动补 s——agy 对没单位的值直接报错落回 help)、XMSG_AGY_WAKE_COOLDOWN
(同一会话冷却秒数,默认 120,防突发 send 派生一堆 resume 抢锁)、
XMSG_AGY_WAKE_ARGS(追加参数,如 --dangerously-skip-permissions)、
XMSG_AGY_PRESENCE_DIR / XMSG_PROC_ROOT(判活输入)。唤醒进程的输出追加在
~/.local/share/agent-msg/agy-wake.log,事后可诊断"唤醒了但没认领"。
agy 会话作为发送方时没有可自动识别的会话环境变量,需按通用约定导出
XMSG_FROM_TOOL=agy XMSG_FROM_SESSION=<conversationId>(否则按 unattributed 处理)。
| 路径 | 作用 |
|---|---|
~/agent-msg/xmsg.py |
发送端 CLI、接收端 hook、会话发现及投递调度,仅使用 Python 标准库 |
~/agent-msg/codex_delivery.py |
Codex 版本/能力判断与可见 app-server 投递 |
~/agent-msg/xmsg-hook.sh |
hook 入口,负责 fail-open 与超时兜底 |
~/agent-msg/bin/xmsg |
薄 dispatcher,软链进 PATH 后就是命令名 xmsg |
~/agent-msg/codex_history.py |
跨 provider 的只读历史索引与安全恢复选择器;其 Unix WebSocket 客户端供投递模块复用 |
~/agent-msg/bin/codex-history |
选择器命令入口 |
~/agent-msg/tests/test_xmsg.py |
单元测试(含 idle 直投、peer: 远程前缀) |
~/agent-msg/tests/test_visible_delivery.py |
版本边界、可见消息来源、真实 TUI 探测和防重复投递回归测试 |
~/agent-msg/tests/test_codex_history.py |
选择、恢复防护、只读索引及 Unix WebSocket 协议测试 |
~/.local/share/agent-msg/messages.sqlite3 |
消息队列(本机运行态,不进版本库) |
~/.local/share/agent-msg/agy-wake.log |
agy 离线唤醒(headless resume)进程的输出日志(运行态) |
消息库故意不用 ~/.agent-memory/index.sqlite3:那个库每分钟被 systemd timer
mirror 一次、并且可从 Markdown 重建,而消息行两个性质都不具备,掺进去只会互相干扰。
xmsg list # 哪些会话现在能收(类似 ListAgents)
xmsg list --all # 连已经安静下来的一起列
# DIRECT=yes 表示现在直投就能触达(哪怕对方 idle)
xmsg queue # 同时查看 xmsg hook 队列和 Codex 官方下一轮队列
xmsg queue --name leader # 按自定义会话名筛选(重名会列候选,不会误投)
xmsg-find-session leader # 只输出自定义会话名对应的完整 session id
xmsg-find-session leader --json # 输出完整发现记录
codex-history list # 跨 provider 列出 Codex 历史
codex-history list --provider tianzhi --query 编排
codex-history list --include-archived
codex-history resume # fzf(没有 fzf 时为编号选择)
codex-history resume <完整 UUID> --dry-run
codex-history export <完整 UUID> --output /tmp/context.md
codex-history export <完整 UUID> --full --output /tmp/context-full.md
xmsg send 01a04cbe "把 #163 的结论同步给我" # 全 id 或 >=4 字符的唯一前缀
xmsg send leader "请优先看这个" # 自定义名;完整 session id 优先
xmsg send leader "紧急提醒" --urgent # 仅提高 xmsg hook 队列优先级
xmsg send all "所有人停一下" # 广播给所有活跃会话
echo "长内容" | xmsg send 01a04cbe - # 从 stdin 读正文
xmsg send 01a04cbe "..." --no-direct # 只排队,不直投(排查用)
xmsg send leader "按现有任务执行本轮巡检" --notification --from agentforge-leader-patrol
# 本机自动提醒;不继承 agent 身份或增加授权
xmsg send peer:leaderpc "对面那台的会话" # 在另一台机器上投递(要配 XMSG_REMOTE)
xmsg list --peer # 列出对面机器上现在能收的会话
xmsg name b9126cf9 agy # 给会话命名,之后 `xmsg send agy` 直达(空名字清除)
xmsg outbox # 我发的还有哪些没投出去
xmsg outbox --all # 含已投递 / 已过期
xmsg cancel 7 # 撤回一条还没投出去的
xmsg doctor # 配置与队列健康接收端 hook 投递消息时,可选地向该会话的控制终端打一行单行横幅
(📨 xmsg: 收到 N 条跨会话消息(来源: …)),XMSG_TTY_BANNER=1 显式开启。
默认关闭,这是真机反馈换来的结论:在宿主 TUI(claude/agy)里,hook 子进程写
/dev/tty 的字节会落在终端当前物理光标位置——也就是宿主的输入框——且宿主
对这些字节毫不知情,重绘时横幅错位、盖住输入框(2026-10-02 agy 真机实测:
单行、CRLF、宽度截断都救不了,落点本身不受 hook 控制)。消息本体已经过
injectSteps/additionalContext 注入并由宿主 UI 渲染给用户,横幅只是给非 TUI
场景(headless 跑 agent、想要终端痕迹的操作者)的可选增强。
开启时形态仍然保守:单行、\r\n 行尾(raw 模式下裸 \n 呈阶梯状)、按终端
宽度截断(中文/全角按 2 列,unicodedata.east_asian_width)、来源与正文剥离
ANSI 转义和控制字符(横幅文本是同伴可控输入,防终端注入)、非阻塞写——它在
消息认领之后才打印,若在流控停住的终端上阻塞,wrapper 的 timeout 会连注入输出
一起杀掉(消息标记已投递而模型没收到),因此写不进去就放弃,绝不等待。
会话 id 从哪来:xmsg list。一个会话在第一次工具调用时自动注册成可投递目标,
带上 cwd、model,以及直投需要的 pid / socket / 权限模式。没跑过任何工具调用的会话
不会出现在 xmsg peers 表里;list / send 还会合并 Codex session_index.jsonl、Claude
custom-title.json,以及此刻仍在听 UDS 的 Claude host(/run/user/.../cc-socks)。
所以一个只停在提示符、从没跑过工具调用的会话,现在也能按自定义名找到、也能直投。
解析顺序固定为:
完整 session id → 精确自定义名 → id 前缀。自定义名有多个历史/活跃候选时会拒绝发送并列出
完整 id,避免“leader”之类常见名称误投。
自定义名除了来自 host 自己的索引(Claude custom-title.json、Codex thread_name),
还可以用 xmsg name <session> <label> 写进 peer 行 —— 这是 agy 这类没有名字索引的
host 唯一的命名通道。hook 每轮注册不会冲掉它;被命名的会话作为发送方时,若没有导出
XMSG_FROM,会自动用这个名字署名,接收方按名字回信即可解析。
定时器和本机脚本可显式使用 send --notification。它只声明「本机自动提醒」类型,
只显示一行通知来源加正文,不是用户的新指令,也不会伪造 session 或权限。
本次发送不继承 XMSG_FROM_TOOL / XMSG_FROM_SESSION / Codex / Claude 会话身份,
不走原生 Codex 委派,Claude 的权限信封仍不编造 from-mode。普通未知 CLI 使用
「未知来源;非用户指令」短标记,单改 --from 显示名不会隐藏来源类型;批次用空行分隔。
通知模式仅支持本机目标,peer: 会明确拒绝,避免把远端通知标为本机来源。
xmsg queue 是统一的只读观察命令:xmsg 行显示 hook-next-tool,Codex 行显示
next-turn。Codex 官方队列没有优先级参数,也不会被 --urgent 改写;要把文字追加到
Codex 当前进行中的 turn,官方交互快捷键是 Enter(steer),Tab 才是 queue(下一轮)。
新版脚本优先调用 app-server 当前轮可见入口;只有官方 queue 路径会明确写“下一轮”。
如果脚本或人工操作只需要完整 session id,可以使用独立的
xmsg-find-session <自定义名>。它复用同一套发现和解析规则:完整 id 优先、其次精确
自定义名,再其次 id 前缀;重名会列出候选并以非零状态退出,不会猜一个发送。
codex-history 统一的是「查找和选择」,不是把不同 provider 的 JSONL/SQLite
拼成一份历史。它默认通过 Codex CLI 自带的 managed app-server thread/list 查询;
modelProviders=[] 表示所有 provider,结果按 updated_at 倒序分页。查询只读,
不会启动模型、重写 rollout 或修改 provider 配置。若本机没有 managed daemon,先运行:
codex app-server daemon bootstrap # 一次性安装本地 daemon 管理
codex app-server daemon start客户端使用 app-server 的本地 Unix WebSocket 控制 socket,并在连接后执行
initialize/initialized,因此不会把 stdio JSONL 当成控制协议。若 daemon 仍不可用,
list 会明确提示并只读降级到最新的 state_<n>.sqlite;可以用
--backend app-server 强制失败以排查环境,或用 --backend sqlite 明确选择保底。
保底不会创建、写入、迁移或合并 SQLite,也不会扫描旧代数据库。API 返回缺少的 model/name
可按同一 ID 从本地索引补齐,但不会把 API 未返回的记录混入结果。所有数据源都在同一个
CODEX_HOME 下;--home 可以选择另一套 Codex home,不会跨 home 合库。
bootstrap 的管理方式依平台而异,以命令的 JSON 输出为准;它可能启用 managed CLI
自动更新。本工具不会修改该策略、不启用远程控制,也不开放 TCP 端口。
展示字段包含完整 ID、名称、provider、model、cwd、更新时间、归档和状态。选择规则是:
完整 UUID → 精确自定义名称 → 唯一 ID 前缀;名称或前缀重名时拒绝猜测并列出完整 ID。
没有 fzf 时退化为编号选择;非 TTY 不会自动选唯一结果,脚本应使用完整 ID。
恢复时默认沿用历史记录的 provider、model 和 cwd,通过参数数组调用官方
codex resume <UUID>。provider/model 缺失、provider 已从配置删除、cwd 不存在、会话
已归档或检测到活跃进程时会停止并说明原因;不会静默更换 provider、取消归档或进入当前目录。
notLoaded 只表示不在所连接的 daemon 内,unknown 表示缺少状态证据,都不能证明没有
另一独立 CLI 在使用该历史。PID + 启动时间的额外检查复用 xmsg peers;未安装 hook 的
独立 CLI 可能无法被此检查识别,恢复前仍应确认原终端已退出。
若确实要换 provider,必须同时使用 --switch-provider <name> --model <model>,并确认一次:
codex-history resume <UUID> --switch-provider tianzhi --model <model> --dry-run
codex-history resume <UUID> --switch-provider tianzhi --model <model>这会明确提示「历史上下文将发送给新 provider」。历史实际上共存在同一个 Codex home,
本工具不改写旧记录的 provider 来伪装统一;显式跨 provider 恢复后的新轮次仍由 Codex 持久化。
--dry-run 只预览命令,不启动 Codex,也不向 provider 发送任何历史。
跨 provider 的原生 resume 可能因旧 rollout 中的 provider 专属 Responses item ID
(例如 at_...)被新 provider 拒绝。需要把上下文交给另一模型时,使用 export:
codex-history export <UUID> --output /tmp/codex-context.md
# 然后在新的 GPT 会话中让它读取该文件并继续导出是 provider-neutral Markdown:保留用户/助手文本、工具调用和工具结果,但不复制
消息 ID、内部 metadata 或加密 reasoning,因此不会让新 provider 重放旧的 Responses 链。
默认模式会截断过长工具输出;--full 保留完整工具输出和可见的思路摘要(仍不导出加密
reasoning)。它是上下文交接,不是原生 resume;旧审批、队列和运行中的工具不会迁移。
send 的输出会说清走了哪条路:
delivered #1 -> 948dd032-... (from peer-a, direct to idle session)
queued #2 -> 32ac2775-... (from peer-a, ttl 3600s; direct: pid 1874635 is gone)
第二行那种「直投没成、已排队」是正常降级,不是错误 —— 对方下次调工具时 hook 会投。
agent 来源默认识别 Codex/Claude 会话环境变量;未自动提供时可显式声明:
XMSG_FROM="codex-repo审查" XMSG_FROM_TOOL=codex XMSG_FROM_SESSION=$SESSION_ID \
xmsg send <target> "..."显式字段及可识别的会话环境变量均缺失时,send 会在 stderr 提醒来源未知。
Claude 使用实际子进程环境 CLAUDE_CODE_SESSION_ID;${CLAUDE_SESSION_ID} 是技能模板占位符,
不据此推断会话身份。嵌套启动导致同时继承 Codex/Claude 环境时,按最近的真实 host 进程区分来源。
原生 Codex 委派只显示原始正文,来源由 Codex 自己的 Sent by 提供。
hook 和可见 user-input 兼容通道统一只添加一行必要来源:
[同伴 leader · codex:01a04cd2;非用户指令]
消息正文
[本机通知 agentforge-leader-patrol;非用户指令]
巡检提醒正文
[未知来源 leader · claude;非用户指令]
来自 Claude、但没有会话 ID 的正文
没有额外计数头、消息 ID、时间戳、XML 信封或回复指南;这些诊断仍留在 outbox。
来源声明不是认证,自定义 label 不能取代 claude / codex 工具名;未知来源也不会冒充用户。
Claude UDS 只保留 host 要求的最小 <cross-session-message> 权限信封,其 body 不叠加 xmsg 包装。
信封里的 from 是接收方照抄回去就能回信的地址,所以它只放发送方的会话名(leader),
不拼工具名 —— claude:leader / claude:1515212e 这类复合串 resolve_target 三条规则
(完整 id / 精确会话名 / id 前缀)一条都匹配不上,接收方想回信会找不到人。发送方是哪个工具
由 from-session 和 outbox 提供,那两处都不需要接收方重新输入。
label 未显式指定时按此顺序取:XMSG_FROM → 本机索引里的会话名(Claude 读
custom-title.json,Codex 读 session_index.jsonl 的最后一次改名)→ 会话 id 前 8 位。
三者都是 xmsg send 能解析的地址。会话名含非 ASCII 字符(审查-会话)时,host 的
from 字符类不接受,信封改放 id 前缀而不是丢掉整个 from(丢了接收方就没有地址可回),
完整名字仍留在 outbox。
一条消息只投一次。claim() 用 BEGIN IMMEDIATE + UPDATE ... RETURNING
把「读出来」和「标记已投递」压成一个原子步骤。
为什么不做「投到接收方确认为止」:PreToolUse 每轮触发几十次,重投的代价是
同一条消息在一次对话里出现十几遍——上下文被灌满、模型反复响应同一件事,比丢一条
消息糟得多。而且「确认」在这个链路上没有可信信号:hook 返回 0 只说明 host 收下了
additionalContext,说明不了模型读进去了;要真做确认就得让接收方回调一次
xmsg ack,那又变回需要接收方主动配合的拉取式。
代价是消息可能丢:接收方会话在投递窗口内退出,消息就没了。用 TTL 兜住—— 默认 3600s 内没投出去就标记 expired 而不是无限期排队。不加 TTL 的后果是 消息落到未来某个偶然复用同一 id 的会话上,任意远地脱离上下文。
其它几条边界,都是 PreToolUse 高频触发逼出来的:
- 单次注入上限 10 条 / 单条 8000 字符,超出的留到下一个工具调用,不丢也不一次灌完。
- 会话安静 30 分钟后不再作为投递目标出现。活跃会话靠工具调用不断刷新这个时间戳, 唯一的变安静方式就是真的停了。
消息读完不是立刻删,而是进入一个保留窗口 —— xmsg outbox 得能回答
「我发的那条投到了没」。窗口过了就 DELETE,不是永久存着。
| 数据 | 保留多久 | 之后 |
|---|---|---|
| 已投递 / 已过期的消息 | 14 天(XMSG_RETAIN_SECONDS) |
删行 |
| peers 注册记录 | 30 天(XMSG_RETAIN_PEER_SECONDS) |
删行 |
| 排队中未投递的消息 | TTL 1 小时(XMSG_TTL_SECONDS) |
标 expired,再按上面第一行删 |
清理跑在三个地方:send(每次)、doctor(每次,且会报清了多少)、
hook 路径(节流到约每小时一次)。
为什么 hook 也要跑:清理原先只在 send 时做。一台只收不发的机器就永远不清 ——
实测造 50 条 100 天前投递的行,跑 20 次 hook 加一次 doctor 都清不掉。所以 hook 路径
也得清,但它每轮触发几十次、预算要留给投递,于是用 meta.last_sweep_at 节流:
先抢着写时间戳再干活,抢不到就跳过。实测 40 次 hook 只清一次,单次 hook 约 37ms
(含 python 启动),远在 3s 兜底预算内。清理失败被单独 catch 掉,不影响已领取的消息。
光 DELETE 不够,文件不会自己缩:SQLite 的 DELETE 只把页还进 freelist,
文件停在历史最高水位。所以 freelist 超过 256 页(约 1MB)时跑一次 VACUUM。
实测灌 300 条 3KB 消息把库撑到 1.24MB,清理后回到 36KB(收缩 98%,freelist 归零)。
没到阈值不 VACUUM 也不是泄漏:那些空页会被后续消息复用。实测清理后停在 528KB / 120 空页,再灌同样一批 120 条 3KB 消息,文件一个字节都没长。所以阈值以下跳过 只是「不急着把空间还给文件系统」,不是空间失控 —— 稳态下库的大小由峰值流量决定, 而不是随时间无限增长。常路径就是两条 pragma,不做重写。
hook 跑在别人的会话里,所以 xmsg-hook.sh 的兜底是硬要求而非防御性编程:
- 不用
set -e,每步|| true timeout 3s(host 给 5s),把挂死切断在预算内- stderr 丢弃,退出码无条件 0
- Python 侧另有一层
except BaseException
实测覆盖 6 种坏法都是 rc=0 无输出:实现文件被删、库文件损坏、库目录不可写、 payload 非 JSON、payload 空、payload 缺 session_id。加上挂死被 3s 切断。
XMSG_NO_FAILOPEN=1 把兜底全关掉。它存在的唯一目的是让「兜底有效」这件事
可被证伪——设上之后同样这些场景真的会以 rc=1/2/124 失败。
Claude Code 对 PreToolUse hook 的退出码是分级处理的。用「只有真跑 Bash
才能读到的随机 secret」当判据,起真实会话逐个测出来:
| hook 退出码 | 工具是否执行 |
|---|---|
| 0 | 执行 |
| 1 | 执行(stderr 进日志,但不阻断) |
| 2 | 不执行,工具被阻断 |
对应到三种坏法(同样用 secret 判据,真实会话,双向各跑一遍):
| 坏法 | 裸跑的退出码 | 无兜底时 | 有兜底时 |
|---|---|---|---|
| 实现文件被删 | 2 | 工具被阻断,会话废掉 | 正常执行 |
| 库文件损坏 | 1 | 正常执行 | 正常执行 |
| 实现挂死 | —(host 自己 5s 超时) | 正常执行,但每次工具调用多等 5s | 正常执行,多等 3s |
所以兜底真正救命的是第一行——而那恰好是这台机器上真实发生过的事故形态:
.codex/hooks.json 引用的 codex-compaction-checkpoint/ 没同步过来,
hook 报 Errno 2。第二、三行兜底是冗余的,属于纵深防御而非承重结构。
写「无条件 exit 0」而不是「只吞掉 exit 2」是刻意的:这样就不必知道每个 host 各自的阻断约定(Codex 侧的退出码分级我没有实测,因为 wrapper 恒返回 0 让这个问题不必回答)。
python3 -m unittest discover -s ~/agent-msg/tests -q # xmsg + codex-history覆盖投递、幂等(含 8 线程并发只准一条命中)、定址(前缀/歧义/广播/过期 peer)、
Stop 窗口 7 例(block 输出形式、防循环守卫、守卫不吃消息、两窗口共享
at-most-once)、署名 6 例(具名与未知来源的一行标记、批次空行分隔、
label 伪造不了 session)、清理 9 例(保留窗口内外、只收不发的机器也清、hook 节流、
不误删排队中的消息、VACUUM 真收缩 / 无谓时跳过)、fail-open 8 例、CLI 端到端 7 例。
测试里 run_hook 会强制设 XMSG_IMPL 指向被测副本。这行不能删:
xmsg-hook.sh 的 $impl 默认回落到 $HOME/agent-msg/xmsg.py,不固定的话
改坏一份副本去跑测试,实际执行的仍是装好的那份原件,所有 fail-open 测试
无论怎么破坏都照样全绿(这个假绿在开发时真的发生过)。
| 变量 | 默认 | 说明 |
|---|---|---|
XMSG_DB |
~/.local/share/agent-msg/messages.sqlite3 |
队列位置 |
XMSG_IMPL |
~/agent-msg/xmsg.py |
实现路径,测试用 |
XMSG_TTL_SECONDS |
3600 | 投不出去多久放弃 |
XMSG_PEER_STALE_SECONDS |
1800 | 多久没工具调用就不再列为目标 |
XMSG_MAX_PER_INJECT |
10 | 单次注入条数上限 |
XMSG_MAX_BODY_CHARS |
8000 | 单条正文上限 |
XMSG_HOOK_TIMEOUT |
3 | hook 自我切断时限(秒) |
XMSG_DIRECT_TIMEOUT |
1.5 | Claude 直投超时(秒);已开始写入则标 UNKNOWN,不重发 |
XMSG_CODEX_TIMEOUT |
20 | app-server/queue 超时(秒);写入结果不明时标 UNKNOWN |
XMSG_CODEX_BIN |
which codex |
codex 可执行文件路径,测试用桩 |
XMSG_RETAIN_SECONDS |
1209600 | 已投递行保留多久(14 天),之后删行 |
XMSG_RETAIN_PEER_SECONDS |
2592000 | peers 记录保留多久(30 天) |
XMSG_HOOK_SWEEP_INTERVAL |
3600 | hook 路径最短清理间隔(秒) |
XMSG_VACUUM_FREE_PAGES |
256 | freelist 超过多少页才 VACUUM 收缩文件 |
XMSG_FROM |
本机索引里的会话名,没有则 id 前 8 位 | 发送方 label,也是接收方回信要照抄的地址(只是地址,伪造不了署名) |
XMSG_FROM_TOOL / XMSG_FROM_SESSION |
— | 显式来源;默认识别 CODEX_SESSION_ID / CODEX_THREAD_ID 或 CLAUDE_CODE_SESSION_ID;均缺失则 unattributed |
XMSG_NO_FAILOPEN |
— | =1 关掉全部兜底,仅用于反证 |
XMSG_REMOTE |
peer(若在 PATH) |
在另一台机器上执行一条命令。xmsg 不附带 SSH 助手;这是操作者自己的包装(ssh otherhost、ControlMaster 封装,等等)。peer: 前缀和 xmsg list --peer 走这条 |
XMSG_REMOTE_UP |
peer-up(若在 PATH) |
发送前可选的开通道命令;失败被忽略 |
XMSG_REMOTE_TIMEOUT |
25 | 对面 xmsg send/list 的超时(秒) |
xmsg 的队列和 UDS 都是本机的。要投到另一台机器上的会话,把目标写成 peer:<会话>:
本机 xmsg send 通过 XMSG_REMOTE 在对面再跑一次 xmsg send,对面用它自己的 sqlite 和 socket 投递。
xmsg send peer:leaderpc "把 #163 的结论同步给我"
xmsg send peer:all "所有人对面停一下"
xmsg list --peer
XMSG_REMOTE 是「把一条命令丢到对面去跑」的包装,本仓库不提供这个包装。可以是:
export XMSG_REMOTE="ssh otherhost"
# 或任何 exec 远程 argv 的脚本,例如本机 ~/bin/peerOpenSSH 会把多余参数用空格拼成远程 shell 字符串,所以 xmsg 发给 XMSG_REMOTE 的是一条已经 quote 过的命令,不是拆开的 argv。
没配 XMSG_REMOTE、PATH 上也没有 peer 时,peer: 会立刻报错,不会静默投到本机。
跨机器时 from-mode 不会自动带上:那个值只从接收方机器的 peers 表读出发送方的 permission_mode,而对面没有你这边的 session 行。bypass 接收方会把消息 hold 住等人点 Deliver。对面若配了 crossSessionInbound: accept(两台这边已经是),则直接放行。不要在发送方伪造 from-mode。
需要手工加到两个 host 的配置里,见 hook-config.diff。都是新增独立条目,
不改动 ~/.agent-memory/hooks/ 那条四工具共用的链路。
这一节保留下来,因为推翻它的过程比结论有用。曾经的结论是「UDS 帮不到外部工具」, 2026-08-31 实测证明其中两条论据是错的,直投也因此成了现在的第一个窗口。
背景事实(这部分当初就没错):Claude Code 的每个 host 进程在
/run/user/<uid>/cc-socks/<pid>.sock 上监听,地址以 uds: 为 scheme
(另有 bridge: / did:),带 verifiedPeerPid 与 peerDirOwnerUids 做对端校验。
原推理是:host 把内容拼进上下文只在它自己那几个时点做,所以换传输不会多出窗口,
证据是自带 SendMessage 走 UDS 却仍然 "drain at the receiver's next tool round"。
错在把一个工具的契约当成了 host 的能力上限。 host 收到 inbound peer 消息后会
主动起一个 turn(takeInboundEnqueueTurn),这本身就是一个新的注入时点,
而且是唯一一个不需要对方先有活动的。SendMessage 保守是它自己的选择,
不是 host 做不到。
verifiedPeerPid 不是准入校验,是溯源标注。 它由内核通过 SO_PEERCRED 填写,
用途是告诉接收方「这条消息来自哪个进程」,而不是拦下陌生进程。
准入实际上取决于两件事,都不构成阻挡:
authRequired在非 Windows 平台默认为 false(SSt()返回P()==="windows")。- 目录白名单认
/run/user/<uid>/cc-socks,同 uid 就能连。
协议未公开这点仍然成立,所以直投按 best-effort 实现、失败就回落队列 —— 这是「协议会变」的正确应对,而不是「不能用」的理由。
这条完全站得住,也正是队列继续做主干的原因。实测过 socket 文件的属主进程已经死了、 文件还留在目录里,连上去没人应答。所以直投只对当下活着的会话有意义, 「先排消息、对方稍后收」仍然只能靠比进程活得久的 SQLite。
它还带出了实现里那道 pid 复用防护:pid 会被复用,socket 文件比属主活得久, 所以必须连启动时刻一起记,否则消息可能投进一个陌生进程的上下文。
「换传输没用,瓶颈在 hook 时点」这个结论,错在把 hook 层的限制推广到了整个 host。 当时的实测都是真的(socket 存在、SendMessage 契约如此、死 socket 连不上), 错在从这些事实跨到了「所以谁都做不到」。
方法上的具体教训:查一个校验字段是准入还是标注,得去看它被用在哪个分支 ——
verifiedPeerPid 是拼进 origin 供渲染的,而不是出现在任何 reject 条件里。
当初只看到它被校验过就收了手。
| 工具 | 可用投递窗口 | 状态 |
|---|---|---|
| Claude Code | uds-direct + PreToolUse + Stop |
三者均实测通过。直投走 host 的 unix socket,能触达 idle 会话 |
| Codex CLI 0.151.0 | codex queue + PreToolUse + Stop |
三者均实测通过。直投走官方 codex queue(不是逆向的),同样能触达 idle 会话,见下。Stop 与 Claude 完全同构:payload 带 stop_hook_active/last_assistant_message,decision:block 被采纳(日志打 hook: Stop Blocked)。 |
| Cursor Agent | 无 | 只有 sessionStart 能注入;beforeSubmitPrompt 的 output 只支持 continue/user_message,官方文档明确不支持 context 注入。要接只能降级成开会话时投一次。 |
| Antigravity(agy) | PreInvocation + Stop + agy-wake |
实测通过(2026-10-02)。无直投 API:language server 端口无可用消息路由,remote-control daemon 默认关。运行中会话靠 hook 注入;离线会话由 send 派生 detached agy --conversation headless resume 唤醒,被唤醒轮的 PreInvocation 认领队列(PONG 往返 exit=0 实测)。--print-timeout 必须是 Go duration(600s),裸数字会报错落回 help |
当前 app-server 路径见开头;独立旧 host 提供了一个官方支持的命令:
codex queue --thread <session-id> --message "<text>"2026-08-31 实测(0.151.0):一个 idle 的会话自己把消息取走并回答了。 比 Claude 那条路干净——是公开命令,不是逆向出来的内部端点。
两个差异改变了实现:
1. 消息被渲染成用户输入,没有 peer 框架也没有 hold 闸门。
Claude 那边 host 会把消息包成 <cross-session-message> 并加上「这不是你的用户在说话」
的框架;Codex 这边它直接显示成 › 通报:…,跟用户亲手敲的没有区别。
所以这条兼容路径由 render() 在正文前添加一行来源与「非用户指令」,不添加长包装。
相应地,Claude 那道「bypass 模式收到未声明来源的消息就 hold 住等人确认」的闸门,
在 Codex 侧不存在,from-mode 在这边没有对应物。
2. 它需要磁盘上有 rollout,而「排进去了」不等于「送到了」。
一个还没跑完任何一轮的会话会被拒(no rollout found),这种只能走 hook。
更要紧的是反面情况:会话已经退出但 rollout 还在时,codex queue 会成功并 exit 0,
把消息存起来等将来有人 resume 那个 thread。那不是投递。
所以活跃性检查(pid + 启动时刻)必须跑在调用 codex 之前 ——
只看退出码就标记已投递,会让消息悄悄丢掉。有一条测试专门钉这个:
桩程序设成必然成功,然后断言它对死会话根本没被调用。
顺带一个观察:~/.codex/thread-writer-locks/<id>.lock 在进程退出后仍留着且未被持有,
所以锁文件不能当活跃判据,仍然要靠 pid + 启动时刻。
一次真实场景:Claude 侧的 leader 会话要把一份任务 brief 派给同机的 Codex 会话。
按上面两节的说法应该直接 codex queue 就行,实际卡了三道,记下来免得重走。
① xmsg send 认不出 Codex thread —— 它不是 xmsg 的 peer。
xmsg list 只列出注册过的会话,而注册发生在接收方自己的 hook 里。Codex 侧装的是
SessionStart/PreToolUse 那套 agent-memory hook,不是 xmsg 的 hook,所以那个
thread 从来没往 peers 表写过一行 ⇒ xmsg: no session matches '01a0908d'。
--force 能让它收下,但结果是 queued (hook delivery) 而不是直投 —— 因为
peers 里没有这个 thread 的投递坐标,xmsg 既不知道它是 codex(不会去调
codex queue),也没有 socket 可连。而 Codex 那边没装 xmsg 的 hook,
这条队列消息永远不会有人来领。
xmsg --force 对一个没装 xmsg hook 的外部工具会话,等于把消息扔进黑洞,
而 outbox 只会一直显示 [queued, …s of ttl left],看起来像「还没投出去」而不是
「投不出去」。这两种状态在 outbox 里长得一样。
同一次里还撞到一个小的:--force 时没设 XMSG_FROM_SESSION,消息被标成
unattributed —— 而 unattributed 的语义是「告诉接收方别单独据此行动」(见上文)。
派活的 brief 被标成这个,等于让对方先怀疑再动手。要么补齐 XMSG_FROM_*,
要么别走这条路。
② 正确路径是官方命令,但需要 rollout 已落盘。
codex queue --thread <full-uuid> --message "$(cat brief.md)"--thread 要完整 UUID,不吃前缀(xmsg 那套 >=4 字符唯一前缀 是 xmsg 自己的
便利,不是 codex 的)。thread id 从 ~/.codex/sessions/<Y>/<M>/<D>/rollout-*.jsonl
的文件名里取,或从 ~/.codex/thread-writer-locks/<id>.lock 取。
第一次调用报了:
Error: failed to queue session message: thread/queue/add failed: failed to read thread:
invalid thread-store request: no rollout found for thread id <id> (code -32603)
这就是上一节说的「还没跑完任何一轮的会话会被拒」。fuser 验过,
不是残留锁),而 rollout 文件仍不存在 —— 一个开着但一轮都没跑完的会话就是这个形态。
让人往那个会话里说一句话,rollout 立刻落盘(实测 252KB),同一条命令随即成功。
⇒ 活跃性的三个信号是三件不同的事,别互相顶替:
| 信号 | 证明什么 | 不能证明什么 |
|---|---|---|
| 进程存在 | 会话开着 | 能不能收消息 |
writer lock 被持有(fuser) |
会话开着且在写这个 thread | rollout 已落盘 |
| rollout 文件存在 | codex queue 能收 |
会话还活着(死会话的 rollout 也在,见上节) |
③ exit 0 之后仍然要验落盘。
codex queue 成功时打 Queued message <msg-id> for thread <thread-id>.
按上一节那条「排进去 ≠ 送到了」,这里补一条能直接查的判据:消息落在
~/.codex/queue_1.sqlite 的 queued_items 表(id / thread_id / payload_json),
Codex 领走后该行消失。
sqlite3 'file:'$HOME'/.codex/queue_1.sqlite?mode=ro' \
'select id, thread_id, substr(payload_json,1,60) from queued_items'queued_items
明明有那一行,而 rollout 里四个特征串全部 0 命中,因为消息还在队列里没被消费,
rollout 只记已进入对话的内容。拿 rollout 当判据会得出「投递失败」的错误结论,
然后重发一遍(对方就收到两份)。
所以要判「对方真的开始干了」,看的是 queued_items 那行消失,
不是命令的退出码,也不是 rollout 里有没有你的字。
顺带:两条路都发了怎么办。 ①的队列消息撤回用 xmsg cancel <id>,
不然万一将来 Codex 侧装上了 xmsg hook,它会在 TTL 内把那条陈旧消息领走一次。
上面那节说的是「怎么发到」。这节说的是发到了但对方找不到,以及修它的时候 怎么又发重了。同一天连撞两次,形状不同。
坑一:codex queue 发的消息,对方在 xmsg outbox 里看不到。
两套是不同的库:
| 发送方式 | 消息落在 |
|---|---|
codex queue --thread … |
~/.codex/queue_1.sqlite 的 queued_items |
xmsg send … |
~/.local/share/agent-msg/messages.sqlite3 |
我用 codex queue 发了两条重要消息,对方去 xmsg 那侧找 —— 只看到一条无关的
探针和一条 EXPIRED undelivered after 7200s 的(那正是上一节说的「--force 扔黑洞」,
它真的黑洞了)。两条真消息在它的视野里根本不存在。
⇒ 判据:收件方用哪个工具找,就用那个工具发。 想让消息在 xmsg outbox 里可追踪,
就走 xmsg send;直接敲 codex queue 等于绕过 xmsg 的账本。
坑二:修坑一的时候,同一条消息投了两份。
发现对方找不到,我用 xmsg send 重发了一次,结果:
delivered #30 -> 01a0908d… (from leader, direct to idle session)
看着完美 —— delivered 不是 queued,还带了署名。但 queued_items 变成了 3 行。
这是当时旧版本的行为:direct_send_codex 底层统一调用 codex queue,所以它和
手工发的那两条进了同一个队列,对方下一轮一次收到三份,其中两份是同一条消息。
当前版本优先使用可见 app-server API,具体顺序见前面的「Codex 版本与能力选择」;
本节保留历史事故,不能据此断言新版发送总会进入官方 queue。
⇒ 两条判据:
- 当时的
xmsg send是官方 queue 的封装。 新版即使选用不同入口,也不会替 手工 queue 消息去重;未经核对就换通道重发仍可能投两份。 ⚠️ xmsg outbox只显示 xmsg 自己那一份,手工codex queue发的那些它看不见 —— 两个来源在收件侧无法区分,在发件侧也无法在一个地方看全。要数「对方将收到几份」, 判据是queued_items的行数,不是xmsg outbox。xmsg cancel只能撤 xmsg 自己库里的,撤不了手工codex queue排进去的(那个 队列没有撤回命令)。所以重复一旦造成,只能在消息正文里说清哪条有效。
还有一个更早就该发现的变体:上一节说「判『对方开始干了』看 queued_items 那行消失」,
这话不完整 —— 归零只能证明某条被领了,不能证明「我最后发的那条」被领了。
我就是看到 queued_items: 0 就宣布消息已送达,而那个 0 是上一条被领走后的空队列,
我那条是在那之后才排进去的。⇒ 比对 id,不是数行数。
发送方自报身份别忘了。 手工 codex queue 没有署名机制;旧版 xmsg send 不设
XMSG_FROM_SESSION 时消息被标成 unattributed(新版还会默认识别 Codex/Claude 会话环境变量),而那个标记的语义是「告诉接收方
别单独据此行动」。给下级派活或向上级请示时带着这个标记,语气就错了。正确形态:
XMSG_FROM_TOOL=claude XMSG_FROM_SESSION=<自己的 session id> \
xmsg send <target> --from leader < brief.md自己的 session id 可以从 peers 表按自己的 pid 反查(Claude 侧 pid 就是 $PPID)。
Codex 把每个 hook 条目的哈希记在 ~/.codex/config.toml 的
[hooks.state."<hooks.json 绝对路径>:<event>:<组下标>:<hook 下标>"] 下。
未授信的条目被静默跳过 —— 不报错、不提示,看起来就像 hook 没写对。
两次踩法:
- 隔离
CODEX_HOME做测试时,拷进去的config.toml里那些键锚定的是 原来那个绝对路径,新位置的 hooks.json 一条都不信任 → 全部静默跳过。 测试场景加--dangerously-bypass-hook-trust即可。 - 往已有
Stop数组追加第二组后,它是个新键(stop:1:0),同样未授信 → 实测不带 bypass 只跑 1 个 Stop hook,带 bypass 跑 2 个。已有条目的哈希不受影响, agent-memory 那条照常工作。
授信要走一次交互式会话确认(codex 交互模式起一次,它会问)。
没有非交互的授信子命令;手工往 config.toml 写 trusted_hash 等于替自己伪造一条
信任记录、跳过 Codex 特意设的审阅环节 —— 别那么干。
判断某条到底生效没有:数 hook: <Event> 出现几次,而不是看有没有报错。
MIT,见 LICENSE。