cross-agent-teams-mcp 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,36 @@ Common flags:
30
30
 
31
31
  ## 2. Configure your agent's MCP client
32
32
 
33
- ### Claude Code (needs both entries — HTTP for tools, stdio for channel wake)
33
+ ### Recommended: install via `mcpsmgr`
34
+
35
+ The fastest path is the [`mcpsmgr`](https://www.npmjs.com/package/mcpsmgr) CLI. It reads this repo's manifest (`mcpsmgr.json`) and writes the right MCP entries (and any required stdio proxy entries) into your agent's config in one shot.
36
+
37
+ ```bash
38
+ cd <your-project>
39
+
40
+ # Pick one or run the bare command for an interactive picker.
41
+ npx mcpsmgr add jtianling/cross-agent-teams-mcp -a claude-code
42
+ npx mcpsmgr add jtianling/cross-agent-teams-mcp -a codex
43
+ npx mcpsmgr add jtianling/cross-agent-teams-mcp # interactive
44
+ ```
45
+
46
+ What it does:
47
+
48
+ - For Claude Code, writes both `.mcp.json` entries (the HTTP server AND the `cross-agent-teams-channel` stdio proxy) — you don't have to know there are two.
49
+ - For Codex, writes `~/.codex/config.toml` with `experimental_use_rmcp_client = true` plus the streamable-http MCP entry.
50
+ - Prints the post-install steps you still need to run yourself (e.g. the `--dangerously-load-development-channels server:cross-agent-teams-channel` flag for Claude Code, or the codex `--remote` setup if you want push wake).
51
+
52
+ Override the daemon port:
53
+
54
+ ```bash
55
+ npx mcpsmgr add jtianling/cross-agent-teams-mcp -a claude-code --port 9300
56
+ ```
57
+
58
+ ### Manual config
59
+
60
+ If you don't want `mcpsmgr` (private fork, custom token, custom stdio args, or you just prefer hand-edited config), the raw per-agent configs are below.
61
+
62
+ #### Claude Code (needs both entries — HTTP for tools, stdio for channel wake)
34
63
 
35
64
  `.mcp.json` (or `~/.claude.json`):
36
65
 
@@ -64,32 +93,92 @@ claude --dangerously-load-development-channels server:cross-agent-teams-channel
64
93
 
65
94
  The `server:<name>` suffix MUST equal the MCP server key in `.mcp.json` (`cross-agent-teams-channel` above). If your daemon uses `--token <t>`, add `"headers": { "Authorization": "Bearer <t>" }` to the HTTP entry.
66
95
 
67
- ### Codex CLI
96
+ #### Codex CLI
97
+
98
+ Codex talks to the daemon over Streamable HTTP. Wake-ups go through Codex's own app-server WebSocket transport — there is no channel proxy involved.
68
99
 
69
- Codex talks to the daemon directly over Streamable HTTP. No channel proxy is needed — Codex has no `claude/channel` capability, and wake-ups are delivered via Codex's own app-server websocket transport (or tmux paste fallback).
100
+ ##### Minimum config (mailbox only, no push wake)
70
101
 
71
102
  `~/.codex/config.toml`:
72
103
 
73
104
  ```toml
74
- [mcp_servers.cross-agent-teams]
105
+ experimental_use_rmcp_client = true
106
+
107
+ [mcp_servers.cross-agent-teams-mcp]
75
108
  type = "streamable-http"
76
109
  url = "http://127.0.0.1:9100/mcp"
77
110
  ```
78
111
 
79
- (daemon with `--token <t>`: add `[mcp_servers.cross-agent-teams.headers]` and `Authorization = "Bearer <t>"`.)
112
+ `experimental_use_rmcp_client = true` MUST sit at the top level — without it, streamable-http MCP servers fail to load.
113
+
114
+ (With `--token <t>` on the daemon: add `[mcp_servers.cross-agent-teams-mcp.headers]` and `Authorization = "Bearer <t>"`.)
115
+
116
+ In this minimum mode, `send_message` to this Codex still drops a row in its mailbox, but you have to call `get_inbox` yourself to read it — no push wake.
117
+
118
+ ##### Let other agents wake you (codex-appserver poke)
119
+
120
+ To let other agents **wake** this Codex thread (not just mail it), you need `codex-appserver` delivery. The setup has one non-obvious gotcha worth calling out:
80
121
 
81
- If you want other agents to be able to **wake** this Codex thread (not only mail it), start Codex's app-server alongside Codex itself:
122
+ > **In `codex --remote` mode, MCP servers are loaded by the app-server, NOT by the TUI.** The MCP entry above must therefore live in the `CODEX_HOME` that the **app-server** reads at startup — usually the global `~/.codex/config.toml`. Setting `CODEX_HOME` on the TUI alone does nothing for MCP under `--remote`.
123
+
124
+ Start order:
82
125
 
83
126
  ```bash
84
- codex app-server --listen ws://127.0.0.1:8799 # in one terminal
85
- codex --remote ws://127.0.0.1:8799 # in another terminal (TUI)
127
+ # 1) Long-lived codex app-server somewhere (its CODEX_HOME decides the MCP set).
128
+ codex app-server --listen ws://127.0.0.1:8799
129
+
130
+ # 2) Codex TUI in a separate terminal, connected to the same app-server.
131
+ codex --remote ws://127.0.0.1:8799
132
+ ```
133
+
134
+ If the app-server's `CODEX_HOME` doesn't have `cross-agent-teams-mcp` configured, the codex agent inside `--remote` won't see the MCP tools at all and `register_agent` will never fire.
135
+
136
+ ##### Recommended: launcher with tmux pane auto-bind
137
+
138
+ For pokes to be injected directly into the running Codex thread (rather than landing as a tmux paste), the daemon needs to know which tmux pane the codex process lives in. The launcher pre-claims a pane via the `pre-register-codex-pane` CLI before exec'ing codex. Add to `~/.zshrc`:
139
+
140
+ ```zsh
141
+ free-xats-codex() {
142
+ local xats_agent_id codex_home search_dir
143
+ xats_agent_id="$(uuidgen)"
144
+ search_dir="$PWD"
145
+ while [[ "$search_dir" != "/" ]]; do
146
+ if [[ -f "$search_dir/.codex/config.toml" ]]; then
147
+ codex_home="$search_dir/.codex"
148
+ break
149
+ fi
150
+ search_dir="${search_dir:h}"
151
+ done
152
+
153
+ if [[ -n "$TMUX_PANE" ]]; then
154
+ npx -y cross-agent-teams-mcp pre-register-codex-pane \
155
+ --pane "$TMUX_PANE" \
156
+ --agent-id "$xats_agent_id" \
157
+ >/dev/null 2>&1 \
158
+ || echo "[xats] pre-register failed (continuing without pane claim)" >&2
159
+ fi
160
+
161
+ if [[ -n "$codex_home" ]]; then
162
+ CODEX_HOME="$codex_home" exec codex \
163
+ --remote ws://127.0.0.1:8799 \
164
+ -c xats.agent_id="\"$xats_agent_id\"" "$@"
165
+ else
166
+ exec codex \
167
+ --remote ws://127.0.0.1:8799 \
168
+ -c xats.agent_id="\"$xats_agent_id\"" "$@"
169
+ fi
170
+ }
86
171
  ```
87
172
 
88
- Without an app-server, `send_message` to this Codex still queues a mailbox row, but you have to call `get_inbox` yourself to read it — there is no push to wake the thread.
173
+ What the launcher does:
174
+
175
+ - Inside tmux (`$TMUX_PANE` set): pre-registers the pane → uuid mapping with the daemon (120s TTL). When the codex agent later calls `register_agent({agent_type: "codex", thread_id: $CODEX_THREAD_ID, ...})`, the daemon resolves `tmux_pane_id` automatically by matching the pre-reg against the codex argv.
176
+ - `--remote ws://127.0.0.1:8799` connects to the long-lived app-server from step (1) above.
177
+ - `-c xats.agent_id="\"$uuid\""` exposes the uuid in codex's argv so the daemon can verify the pane.
89
178
 
90
- Detailed config (auth headers, tmux fallback, lower-level `register_agent` form): [docs/configs/codex-cli.md](docs/configs/codex-cli.md).
179
+ More detail (auth headers, lower-level `register_agent` form): [docs/configs/codex-cli.md](docs/configs/codex-cli.md).
91
180
 
92
- ### Other coding agents (opencode, cursor, ...)
181
+ #### Other coding agents (opencode, cursor, ...)
93
182
 
94
183
  Anything that is not Claude Code or Codex — opencode, cursor, an editor extension, your own harness — connects over plain Streamable HTTP and registers as `agent_type="custom"` (the agent figures this out for you). There is no dedicated wake-up transport for these; cross-agent pokes are delivered by injecting text into the agent's tmux pane, so run the agent inside a tmux window and the daemon will resolve `pid → tty → pane` automatically when you register.
95
184
 
package/README.zh-CN.md CHANGED
@@ -30,7 +30,36 @@ daemon 默认监听 `127.0.0.1:9100`. MCP endpoint: `http://127.0.0.1:9100/mcp`
30
30
 
31
31
  ## 2. 在 agent 端配置 MCP client
32
32
 
33
- ### Claude Code (两个条目都需要 — HTTP 用于工具, stdio 用于 channel 唤醒)
33
+ ### 推荐: `mcpsmgr` 安装
34
+
35
+ 最快的方式是用 [`mcpsmgr`](https://www.npmjs.com/package/mcpsmgr) CLI. 它读取本仓库的 manifest (`mcpsmgr.json`), 一次性把对应 agent 需要的 MCP 条目 (含必要的 stdio proxy 条目) 写到 agent 的配置文件里.
36
+
37
+ ```bash
38
+ cd <your-project>
39
+
40
+ # 选一个, 或者用裸命令进入交互式选择.
41
+ npx mcpsmgr add jtianling/cross-agent-teams-mcp -a claude-code
42
+ npx mcpsmgr add jtianling/cross-agent-teams-mcp -a codex
43
+ npx mcpsmgr add jtianling/cross-agent-teams-mcp # 交互式
44
+ ```
45
+
46
+ 它做了什么:
47
+
48
+ - Claude Code: 同时写两条 `.mcp.json` 条目 (HTTP 工具 + `cross-agent-teams-channel` stdio proxy) — 你不用记两条.
49
+ - Codex: 写 `~/.codex/config.toml`, 带上 `experimental_use_rmcp_client = true` 和 streamable-http MCP 条目.
50
+ - 打印你还需要自己跑的 post-install 步骤 (例如 Claude Code 的 `--dangerously-load-development-channels server:cross-agent-teams-channel` 启动 flag, 或 codex 想要 push 唤醒时的 `--remote` 配置).
51
+
52
+ 覆盖 daemon 端口:
53
+
54
+ ```bash
55
+ npx mcpsmgr add jtianling/cross-agent-teams-mcp -a claude-code --port 9300
56
+ ```
57
+
58
+ ### 手动配置
59
+
60
+ 如果不想用 `mcpsmgr` (私有 fork / 自定义 token / 自定义 stdio args / 或者就是想手写), 各 agent 的原始配置如下.
61
+
62
+ #### Claude Code (两个条目都需要 — HTTP 用于工具, stdio 用于 channel 唤醒)
34
63
 
35
64
  `.mcp.json` (或 `~/.claude.json`):
36
65
 
@@ -64,32 +93,92 @@ claude --dangerously-load-development-channels server:cross-agent-teams-channel
64
93
 
65
94
  `server:<name>` 后缀 **必须** 等于 `.mcp.json` 里的 MCP server key (上例中是 `cross-agent-teams-channel`). 如果 daemon 启动带了 `--token <t>`, 在 HTTP 条目里加 `"headers": { "Authorization": "Bearer <t>" }`.
66
95
 
67
- ### Codex CLI
96
+ #### Codex CLI
97
+
98
+ Codex 通过 Streamable HTTP 跟 daemon 通信. 唤醒走 Codex 自己的 app-server WebSocket, 不经 channel proxy.
68
99
 
69
- Codex 直接通过 Streamable HTTP 跟 daemon 通信, 不需要 channel proxy — Codex 没有 `claude/channel` capability, 唤醒走 Codex 自己的 app-server websocket (或 tmux paste 兜底).
100
+ ##### 最小配置 (只能收邮箱, 没有 push 唤醒)
70
101
 
71
102
  `~/.codex/config.toml`:
72
103
 
73
104
  ```toml
74
- [mcp_servers.cross-agent-teams]
105
+ experimental_use_rmcp_client = true
106
+
107
+ [mcp_servers.cross-agent-teams-mcp]
75
108
  type = "streamable-http"
76
109
  url = "http://127.0.0.1:9100/mcp"
77
110
  ```
78
111
 
79
- (daemon 带了 `--token <t>` 时, 加 `[mcp_servers.cross-agent-teams.headers]` `Authorization = "Bearer <t>"`.)
112
+ `experimental_use_rmcp_client = true` 必须放在**顶级**, 缺这条 streamable-http MCP 加载不了.
113
+
114
+ (daemon 带了 `--token <t>` 时, 加 `[mcp_servers.cross-agent-teams-mcp.headers]` 和 `Authorization = "Bearer <t>"`.)
115
+
116
+ 这种最小配置下 `send_message` 给这个 codex 会写邮箱, 但需要手动调 `get_inbox` 拉读, 没有跨会话 push 唤醒.
117
+
118
+ ##### 让别人能唤醒你 (codex-appserver poke)
119
+
120
+ 要让别的 agent 能**主动唤醒**这个 codex thread (而不只是发邮件), 需要 `codex-appserver` delivery. 这里有个不直观的坑要写清楚:
80
121
 
81
- 如果你希望别的 agent 能**唤醒**这个 Codex thread (不只是给它发邮件), 在跑 Codex 之前把 codex app-server 一起拉起来:
122
+ > **`codex --remote` 模式下, MCP server app-server 加载的, 不是 TUI 加载的**. 上面那段 MCP 配置必须放在 **app-server** 启动时读到的 `CODEX_HOME` 里 — 一般就是全局 `~/.codex/config.toml`. 仅在 TUI 这边设 `CODEX_HOME` 在 `--remote` 模式下对 MCP 不起作用.
123
+
124
+ 启动顺序:
82
125
 
83
126
  ```bash
84
- codex app-server --listen ws://127.0.0.1:8799 # 一个终端
85
- codex --remote ws://127.0.0.1:8799 # 另一个终端 (TUI)
127
+ # 1) 在某个长跑终端起 codex app-server (它的 CODEX_HOME 决定 MCP set)
128
+ codex app-server --listen ws://127.0.0.1:8799
129
+
130
+ # 2) 在另一个终端启动 codex TUI, 连同一个 app-server
131
+ codex --remote ws://127.0.0.1:8799
132
+ ```
133
+
134
+ 如果第 1 步的 app-server 的 `CODEX_HOME` 里没配 `cross-agent-teams-mcp`, `--remote` 进去的 codex agent 根本看不到 MCP 工具, `register_agent` 调都调不到.
135
+
136
+ ##### 推荐: launcher 函数 (tmux pane 自动绑定)
137
+
138
+ 为了让 daemon 把 wake-hint 直接 inject 到 codex thread (而不是只 paste 到 tmux pane), daemon 需要知道 codex 进程在哪个 tmux pane. launcher 通过 `pre-register-codex-pane` CLI 在 exec codex 之前先把 pane 占住. 把下面的函数加到 `~/.zshrc`:
139
+
140
+ ```zsh
141
+ free-xats-codex() {
142
+ local xats_agent_id codex_home search_dir
143
+ xats_agent_id="$(uuidgen)"
144
+ search_dir="$PWD"
145
+ while [[ "$search_dir" != "/" ]]; do
146
+ if [[ -f "$search_dir/.codex/config.toml" ]]; then
147
+ codex_home="$search_dir/.codex"
148
+ break
149
+ fi
150
+ search_dir="${search_dir:h}"
151
+ done
152
+
153
+ if [[ -n "$TMUX_PANE" ]]; then
154
+ npx -y cross-agent-teams-mcp pre-register-codex-pane \
155
+ --pane "$TMUX_PANE" \
156
+ --agent-id "$xats_agent_id" \
157
+ >/dev/null 2>&1 \
158
+ || echo "[xats] pre-register failed (continuing without pane claim)" >&2
159
+ fi
160
+
161
+ if [[ -n "$codex_home" ]]; then
162
+ CODEX_HOME="$codex_home" exec codex \
163
+ --remote ws://127.0.0.1:8799 \
164
+ -c xats.agent_id="\"$xats_agent_id\"" "$@"
165
+ else
166
+ exec codex \
167
+ --remote ws://127.0.0.1:8799 \
168
+ -c xats.agent_id="\"$xats_agent_id\"" "$@"
169
+ fi
170
+ }
86
171
  ```
87
172
 
88
- 不启 app-server 也能用 — `send_message` 给这个 Codex 仍然会写到邮箱, 但需要你自己调 `get_inbox` 拉读, 没有推送唤醒.
173
+ 行为:
174
+
175
+ - tmux 内 (`$TMUX_PANE` 非空): 先发一条 pre-register (pane_id + UUID + 120s TTL) 给 daemon. codex agent 之后调 `register_agent({agent_type: "codex", thread_id: $CODEX_THREAD_ID, ...})` 时, daemon 会用 pending pre-reg + 匹配 codex 进程 argv 自动绑 `tmux_pane_id`.
176
+ - `--remote ws://127.0.0.1:8799` 让 codex 连步骤 (1) 起好的 app-server.
177
+ - `-c xats.agent_id="\"$uuid\""` 把 UUID 暴露在 codex argv 里, daemon 用它反向校验 pane.
89
178
 
90
- 详细配置 (auth header, tmux fallback, 底层 `register_agent` 用法): [docs/configs/codex-cli.md](docs/configs/codex-cli.md).
179
+ 详细配置 (auth header, 底层 `register_agent` 用法): [docs/configs/codex-cli.md](docs/configs/codex-cli.md).
91
180
 
92
- ### 其它编码 agent (opencode, cursor, ...)
181
+ #### 其它编码 agent (opencode, cursor, ...)
93
182
 
94
183
  非 Claude Code 也非 Codex 的工具 — opencode, cursor, 编辑器扩展, 自己的 harness — 直接通过 Streamable HTTP 连 daemon, 注册时用 `agent_type="custom"` (agent 自己会判断). 这些 agent 没有专用的唤醒通道; 跨 agent poke 通过把文本注入到 agent 所在的 tmux pane 实现, 所以把 agent 跑在 tmux 窗口里, 注册时 daemon 会自动解析 `pid → tty → pane`.
95
184
 
package/dist/cli.js CHANGED
@@ -195,10 +195,18 @@ function migrateMessagesNeedReplyColumn(db) {
195
195
  if (existing.has("need_reply")) return;
196
196
  db.exec(`ALTER TABLE messages ADD COLUMN need_reply INTEGER NOT NULL DEFAULT 1`);
197
197
  }
198
+ function migrateAgentsCursorWatermark(db) {
199
+ db.exec(
200
+ `UPDATE agents
201
+ SET last_processed_event_id = COALESCE((SELECT MAX(event_id) FROM events), 0)
202
+ WHERE last_processed_event_id = 0`
203
+ );
204
+ }
198
205
  function applySchema(db) {
199
206
  for (const sql of DDL) db.exec(sql);
200
207
  migrateAgentsDeliveryColumns(db);
201
208
  migrateMessagesNeedReplyColumn(db);
209
+ migrateAgentsCursorWatermark(db);
202
210
  }
203
211
 
204
212
  // src/daemon/auth.ts
@@ -425,9 +433,11 @@ var AgentsRepo = class {
425
433
  this.db.prepare(
426
434
  `INSERT INTO agents (
427
435
  agent_id, agent_type, agent_type_name, team, role, name, model, registered_at, last_seen_at,
428
- tmux_pane_id, claude_ui_pid, runtime_ui_pid, delivery_kind, delivery_payload
436
+ tmux_pane_id, claude_ui_pid, runtime_ui_pid, delivery_kind, delivery_payload,
437
+ last_processed_event_id
429
438
  )
430
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
439
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
440
+ COALESCE((SELECT MAX(event_id) FROM events), 0))
431
441
  ON CONFLICT (team, name) DO UPDATE SET
432
442
  agent_type = excluded.agent_type,
433
443
  agent_type_name = excluded.agent_type_name,
@@ -1364,27 +1374,40 @@ var GetInboxService = class {
1364
1374
  const caller = this.agents.findById(args.caller);
1365
1375
  if (!caller) return { messages: [], has_more: false, last_event_id: args.since_event_id ?? 0 };
1366
1376
  const callerTeam = caller.team;
1367
- const callerRole = this.db.prepare("SELECT role FROM agents WHERE agent_id=?").get(args.caller);
1377
+ const callerRoleRow = this.db.prepare("SELECT role, last_processed_event_id FROM agents WHERE agent_id=?").get(args.caller);
1378
+ const callerRole = callerRoleRow?.role;
1379
+ const storedCursor = callerRoleRow?.last_processed_event_id ?? 0;
1368
1380
  const limit = Math.min(args.limit ?? 50, 200);
1369
- const since = args.since_event_id ?? 0;
1370
- const rows = this.db.prepare(
1371
- `SELECT m.id, m.event_id, m.from_team, m.to_team, m.from_agent_id, m.to_agent_id, m.to_role, m.subject, m.body, m.need_reply, m.sent_at,
1372
- a.role as from_role
1373
- FROM messages m
1374
- LEFT JOIN agents a ON a.agent_id = m.from_agent_id
1375
- WHERE m.to_team = ?
1376
- AND m.event_id > ?
1377
- AND ( m.to_agent_id = ? OR (m.to_role IS NOT NULL AND m.to_role = ?) )
1378
- ORDER BY m.event_id ASC
1379
- LIMIT ?`
1380
- ).all(callerTeam, since, args.caller, callerRole?.role ?? "__none__", limit + 1);
1381
- const has_more = rows.length > limit;
1382
- const trimmed = (has_more ? rows.slice(0, limit) : rows).map((row) => ({
1383
- ...row,
1384
- need_reply: row.need_reply === 1
1385
- }));
1386
- const last_event_id = trimmed.length > 0 ? trimmed[trimmed.length - 1].event_id : since;
1387
- return { messages: trimmed, has_more, last_event_id };
1381
+ const implicit = args.since_event_id === void 0;
1382
+ const effectiveSince = implicit ? storedCursor : args.since_event_id;
1383
+ const tx = this.db.transaction(() => {
1384
+ const rows = this.db.prepare(
1385
+ `SELECT m.id, m.event_id, m.from_team, m.to_team, m.from_agent_id, m.to_agent_id, m.to_role, m.subject, m.body, m.need_reply, m.sent_at,
1386
+ a.role as from_role
1387
+ FROM messages m
1388
+ LEFT JOIN agents a ON a.agent_id = m.from_agent_id
1389
+ WHERE m.to_team = ?
1390
+ AND m.event_id > ?
1391
+ AND ( m.to_agent_id = ? OR (m.to_role IS NOT NULL AND m.to_role = ?) )
1392
+ ORDER BY m.event_id ASC
1393
+ LIMIT ?`
1394
+ ).all(callerTeam, effectiveSince, args.caller, callerRole ?? "__none__", limit + 1);
1395
+ const has_more = rows.length > limit;
1396
+ const trimmed = (has_more ? rows.slice(0, limit) : rows).map((row) => ({
1397
+ ...row,
1398
+ need_reply: row.need_reply === 1
1399
+ }));
1400
+ const last_event_id = trimmed.length > 0 ? trimmed[trimmed.length - 1].event_id : effectiveSince;
1401
+ if (implicit && last_event_id > storedCursor) {
1402
+ this.db.prepare(
1403
+ `UPDATE agents
1404
+ SET last_processed_event_id = ?
1405
+ WHERE agent_id = ? AND last_processed_event_id < ?`
1406
+ ).run(last_event_id, args.caller, last_event_id);
1407
+ }
1408
+ return { messages: trimmed, has_more, last_event_id };
1409
+ });
1410
+ return tx();
1388
1411
  }
1389
1412
  };
1390
1413
 
@@ -3701,7 +3724,13 @@ function registerBusinessTools(server, db, getCallerAgentId, fanout, onRegisterS
3701
3724
  "get_inbox",
3702
3725
  {
3703
3726
  title: "Get inbox",
3704
- description: "Return messages addressed to caller after since_event_id",
3727
+ description: [
3728
+ "Return messages addressed to the caller (by agent_id or matching role) within the caller team.",
3729
+ "Default behaviour (since_event_id omitted): the daemon reads the caller's server-side cursor (`agents.last_processed_event_id`), returns mail past it, and ADVANCES the cursor to the highest returned event_id in the same transaction. Subsequent default calls return only newer mail.",
3730
+ "Pagination via `limit` advances the cursor only to the last RETURNED event_id; the next default call resumes from there.",
3731
+ "Explicit `since_event_id` (any number, including 0) is read-only inspection: the daemon uses the supplied value as the lower bound and does NOT advance the stored cursor \u2014 useful for re-reading history or debugging without disturbing live read position.",
3732
+ "Retention: messages older than 30 days are deleted by the cleanup routine regardless of read state. Agents that go offline for more than 30 days forfeit any unread mail in that window."
3733
+ ].join(" "),
3705
3734
  inputSchema: {
3706
3735
  since_event_id: z3.number().int().optional(),
3707
3736
  limit: z3.number().int().optional()
@@ -4210,30 +4239,23 @@ function mountMcp(app, db, fanout, channelWakeFanout, opts = {}) {
4210
4239
  }
4211
4240
 
4212
4241
  // src/daemon/cleanup.ts
4213
- var DELETE_AGED_EVENTS_SQL = `
4214
- WITH online_cursor AS (
4215
- SELECT team AS to_team, MIN(last_processed_event_id) AS min_cursor
4216
- FROM agents
4217
- WHERE last_seen_at >= :cutoffOnline
4218
- GROUP BY team
4219
- )
4220
- DELETE FROM events
4221
- WHERE created_at < :ageCutoff
4222
- AND (
4223
- events.to_team NOT IN (SELECT to_team FROM online_cursor)
4224
- OR events.event_id < (
4225
- SELECT min_cursor FROM online_cursor WHERE online_cursor.to_team = events.to_team
4226
- )
4227
- )
4228
- `;
4229
4242
  function runCleanup(db, opts = {}) {
4230
4243
  const now = opts.now ?? /* @__PURE__ */ new Date();
4231
- const maxAgeDays = opts.maxAgeDays ?? 7;
4232
- const onlineWindowMs = opts.onlineWindowMs ?? 5 * 60 * 1e3;
4244
+ const maxAgeDays = opts.maxAgeDays ?? 30;
4233
4245
  const ageCutoff = new Date(now.getTime() - maxAgeDays * 86400 * 1e3).toISOString();
4234
- const cutoffOnline = new Date(now.getTime() - onlineWindowMs).toISOString();
4235
- const info = db.prepare(DELETE_AGED_EVENTS_SQL).run({ ageCutoff, cutoffOnline });
4236
- return { deleted: Number(info.changes) };
4246
+ const deleteStatus = db.prepare(
4247
+ `DELETE FROM message_delivery_status
4248
+ WHERE message_id IN (SELECT id FROM messages WHERE sent_at < ?)`
4249
+ );
4250
+ const deleteMessages = db.prepare(`DELETE FROM messages WHERE sent_at < ?`);
4251
+ const deleteEvents = db.prepare(`DELETE FROM events WHERE created_at < ?`);
4252
+ const tx = db.transaction(() => {
4253
+ const s = deleteStatus.run(ageCutoff);
4254
+ const m = deleteMessages.run(ageCutoff);
4255
+ const e = deleteEvents.run(ageCutoff);
4256
+ return Number(s.changes) + Number(m.changes) + Number(e.changes);
4257
+ });
4258
+ return { deleted: tx() };
4237
4259
  }
4238
4260
 
4239
4261
  // src/daemon/sse-fanout.ts