Skip to content
HermanShiPublic

About

Cross-session push messaging for CLI AI coding agents — reach an idle Claude Code or Codex session, not just a running one. Python stdlib only, no daemon.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

24 Commits

Folders and files

Repository files navigation

xmsg — 跨会话推送式消息投递

让一个 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 接受或字节写入,不是模型已读确认。

Codex 版本与能力选择

以下为官方源码首次引入提交对应的首个稳定 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。预发行版不能只按数值假定稳定版接口已存在。

路由顺序:

  1. 已运行 daemon 的版本至少 0.145.0:连现有 Unix WebSocket,初始化并只读确认目标已加载且允许输入。
  2. 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 标签或信封。
  3. Claude、cron、未能核验的源身份或旧 TUI:可见输入正文明确标注来源。idle 用 turn/start, active 用最新一条 turn 的 expectedTurnId 调 turn/steer;不恢复 thread、不覆盖模型/权限。
  4. 没有可用 daemon,且是独立 Codex host:运行进程版本至少 0.149.0,CLI queue --help 确实 宣告两个参数后,使用 codex queue,输出明确写下一轮。共享 daemon PID 本身不能证明某 thread 活跃, 因而不能在 thread 查询失败后据此盲目排入官方队列。
  5. 确定未送达时回到 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 展开。

uds-direct:Claude 侧触达 idle 会话的窗口

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 路径。

from-mode:为什么有的消息会停下来等人确认

接收方对 inbound peer 消息有一道 ingress 闸门:当接收方自己是 bypass 权限模式、 而发送方没有声明自己的权限模式时,消息会被 hold 住,在对方终端上渲染成一个 「Deliver / Deny」的选择等人点。声明了且与接收方同级,才直接放行。

声明写在信封的 from-mode 属性里(取值只有 bypass / prompting)。xmsg 填这个值 只从发送会话自己上报过的 permission_mode 推导 —— 该字段就在 hook payload 里, 由接收方在注册时记进 peers,发送方无从指定。

发送方没有 peer 记录时(人在终端敲命令、cron 任务),就不带声明,消息按预期被 hold。 这是正确结果而不是待修的缺口:替发送方编一个声明等于伪造它的权限背书。

Codex → Claude 默认被 hold,解法是 crossSessionInbound: accept + 重启会话

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 那行。

⚠️⚠️ 改完必须重启会话,同一会话内改了不生效。 2026-09-12 实测(bypass 模式的 Claude 会话,发送方是 Codex):

时点 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,代码里还有两个候选:

  1. settings 被判为无效值 —— 有个检查看 errors 里有没有 severity === "warning" 的条目,对应提示:"A settings file has an unrecognized crossSessionInbound value (see the settings warning), so messages are held while it is present"。 ⇒ 自查:/config 看 Messages from your other sessions 那行显示 accept 还是 hold。 显示 hold 就是设置没被采纳(键写错层级、值拼错),不是「没生效」。
  2. 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() 因此逐个属性校验,宁可丢掉一个属性,也不赌整个信封。

hook 的两个窗口为什么都要

PreToolUse 只在这一轮还会再调工具时才有机会。一轮临近结束时没有下一次工具调用了, Stop 就是最后一个还能触达模型的执行点。实测:真实会话里明确要求「不要调用任何工具」, PreToolUse 一次都没触发,消息仍通过 Stop 投达。

Stop 这一路只能用 decision: block,不能用 additionalContext —— 此刻没有待发的 工具调用,additionalContext 无处可拼。block 是唯一能让内容进到模型眼前的输出, 代价是它以「重启这一轮」的方式实现。

stop_hook_active 守卫是承重的,不是防御性代码:被 block 重启的那一轮结束时 Stop 会再触发一次,此时 payload 里 stop_hook_active=true。不据此放行就是一个 永不终止的 block 循环。实测两次触发分别为 false / true,守卫生效。 该守卫不消费消息 —— 被抑制的那次不领取,消息留给重启后那一轮的首次工具调用。

hook 确实没有 idle 窗口(这条仍然成立)

一个真正 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"。

⚠️ 此前据此推出的「所以谁都做不到 idle 投递」是错的:自带 SendMessage 受这个限制, 不代表 host 本身受这个限制 —— host 就是靠 UDS 绕开了它。结论只在 hook 这一层成立, 把它推广到整个 host 是我判错了一次。见上面的 uds-direct。

⚠️ 「Codex 没有 socket 所以只能靠 hook」是旧结论:新版有 Unix WebSocket app-server, 旧版还有官方 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 历史(codex-history)

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。

投递语义:at-most-once,不做已读确认

一条消息只投一次。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,不做重写。

fail-open:坏了顶多不投,绝不能卡住会话

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 的超时(秒)

跨机器:peer: 前缀

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/peer

OpenSSH 会把多余参数用空格拼成远程 shell 字符串,所以 xmsg 发给 XMSG_REMOTE 的是一条已经 quote 过的命令,不是拆开的 argv。

没配 XMSG_REMOTE、PATH 上也没有 peer 时,peer: 会立刻报错,不会静默投到本机。

跨机器时 from-mode 不会自动带上:那个值只从接收方机器的 peers 表读出发送方的 permission_mode,而对面没有你这边的 session 行。bypass 接收方会把消息 hold 住等人点 Deliver。对面若配了 crossSessionInbound: accept(两台这边已经是),则直接放行。不要在发送方伪造 from-mode。

Hook 配置

需要手工加到两个 host 的配置里,见 hook-config.diff。都是新增独立条目, 不改动 ~/.agent-memory/hooks/ 那条四工具共用的链路。

UDS:三条曾经的「不可行」理由,两条是错的

这一节保留下来,因为推翻它的过程比结论有用。曾经的结论是「UDS 帮不到外部工具」, 2026-08-31 实测证明其中两条论据是错的,直投也因此成了现在的第一个窗口。

背景事实(这部分当初就没错):Claude Code 的每个 host 进程在 /run/user/<uid>/cc-socks/<pid>.sock 上监听,地址以 uds: 为 scheme (另有 bridge: / did:),带 verifiedPeerPid 与 peerDirOwnerUids 做对端校验。

❌ 错:「UDS 只决定字节怎么到进程,不决定内容何时进模型上下文」

原推理是:host 把内容拼进上下文只在它自己那几个时点做,所以换传输不会多出窗口, 证据是自带 SendMessage 走 UDS 却仍然 "drain at the receiver's next tool round"。

错在把一个工具的契约当成了 host 的能力上限。 host 收到 inbound peer 消息后会 主动起一个 turn(takeInboundEnqueueTurn),这本身就是一个新的注入时点, 而且是唯一一个不需要对方先有活动的。SendMessage 保守是它自己的选择, 不是 host 做不到。

❌ 错:「协议未公开且按 verifiedPeerPid 校验,所以外部工具进不去」

verifiedPeerPid 不是准入校验,是溯源标注。 它由内核通过 SO_PEERCRED 填写, 用途是告诉接收方「这条消息来自哪个进程」,而不是拦下陌生进程。

准入实际上取决于两件事,都不构成阻挡:

  • authRequired 在非 Windows 平台默认为 false(SSt() 返回 P()==="windows")。
  • 目录白名单认 /run/user/<uid>/cc-socks,同 uid 就能连。

协议未公开这点仍然成立,所以直投按 best-effort 实现、失败就回落队列 —— 这是「协议会变」的正确应对,而不是「不能用」的理由。

✅ 对:「per-PID socket 的生命周期比消息短」

这条完全站得住,也正是队列继续做主干的原因。实测过 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)。⚠️ 新增 hook 条目需一次交互式信任确认,见下
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

旧独立 Codex host 的 queue 降级路径

当前 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 + 启动时刻。

从外面给一个 Codex 会话派活:实测走通的顺序(2026-09-11,0.154.0)

一次真实场景: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)

这就是上一节说的「还没跑完任何一轮的会话会被拒」。⚠️ 但判据不是「进程在不在」: 当时 codex 进程活着(pid 正常)、writer lock 也确实被它持有(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'

⚠️ 不要去 rollout 里 grep 自己的正文来确认送达 —— 那次实测里 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。

⇒ 两条判据:

  1. 当时的 xmsg send 是官方 queue 的封装。 新版即使选用不同入口,也不会替 手工 queue 消息去重;未经核对就换通道重发仍可能投两份。
  2. ⚠️ xmsg outbox 只显示 xmsg 自己那一份,手工 codex queue 发的那些它看不见 —— 两个来源在收件侧无法区分,在发件侧也无法在一个地方看全。要数「对方将收到几份」, 判据是 queued_items 的行数,不是 xmsg outbox。
  3. 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 把每个 hook 条目的哈希记在 ~/.codex/config.toml 的 [hooks.state."<hooks.json 绝对路径>:<event>:<组下标>:<hook 下标>"] 下。 未授信的条目被静默跳过 —— 不报错、不提示,看起来就像 hook 没写对。

两次踩法:

  1. 隔离 CODEX_HOME 做测试时,拷进去的 config.toml 里那些键锚定的是 原来那个绝对路径,新位置的 hooks.json 一条都不信任 → 全部静默跳过。 测试场景加 --dangerously-bypass-hook-trust 即可。
  2. 往已有 Stop 数组追加第二组后,它是个新键(stop:1:0),同样未授信 → 实测不带 bypass 只跑 1 个 Stop hook,带 bypass 跑 2 个。已有条目的哈希不受影响, agent-memory 那条照常工作。

授信要走一次交互式会话确认(codex 交互模式起一次,它会问)。 没有非交互的授信子命令;手工往 config.toml 写 trusted_hash 等于替自己伪造一条 信任记录、跳过 Codex 特意设的审阅环节 —— 别那么干。

判断某条到底生效没有:数 hook: <Event> 出现几次,而不是看有没有报错。

许可证

MIT,见 LICENSE。

About

Cross-session push messaging for CLI AI coding agents — reach an idle Claude Code or Codex session, not just a running one. Python stdlib only, no daemon.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages