cross-agent-teams-mcp 0.2.6 → 0.3.1

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
@@ -6,76 +6,72 @@ An MCP daemon for cross-agent collaboration, with local delivery transports for
6
6
 
7
7
  ## Quick Start
8
8
 
9
- The package ships two bins:
9
+ This package ships two bins on the same npm name: the long-running HTTP daemon (`cross-agent-teams-mcp`) and a stdio channel proxy (`cross-agent-teams-channel`) that lets Claude Code receive `notifications/channel_wake` from the daemon. You start the daemon once on your machine, then point Claude Code at the proxy as an MCP server, and finally launch Claude with the experimental channel loader so it subscribes to wake notifications. There is no auto-bootstrap — the proxy MUST NOT and WILL NOT start a daemon for you; if the daemon is unreachable the proxy fails fast.
10
10
 
11
- - `cross-agent-teams-mcp` the daemon (HTTP MCP server on `127.0.0.1:9100`).
12
- - `cross-agent-teams-channel` — a stdio MCP proxy that Claude Code talks to over stdio. On startup it auto-bootstraps the daemon if no healthy daemon is reachable on the configured loopback URL.
11
+ ### 1. Start the daemon
13
12
 
14
- The channel is now a full stdio↔HTTP transparent proxy: clients see the daemon's complete tool surface (`register_claude_self`, `list_agents`, `register_agent`, `bind_channel`, `send_message`, `task_*`, etc.) plus the `notifications/claude/channel` wake notifications. The proxy-internal tool `subscribe_channel_wake` is filtered out from the host-visible list.
13
+ ```bash
14
+ npx -y cross-agent-teams-mcp@latest daemon --port 9100
15
+ ```
16
+
17
+ Keep this process running (a dedicated terminal, `tmux`, `screen`, or your favourite supervisor). The daemon listens on `127.0.0.1:9100` by default. The MCP endpoint is `http://127.0.0.1:9100/mcp` and the health check endpoint is `http://127.0.0.1:9100/health`.
18
+
19
+ You can verify the service with:
20
+
21
+ ```bash
22
+ curl http://127.0.0.1:9100/health
23
+ ```
24
+
25
+ ### 2. Configure Claude Code's MCP client to use the channel proxy
15
26
 
16
- The recommended Claude Code MCP config collapses to a single stdio entry no manual daemon startup needed:
27
+ Add the channel proxy to `.mcp.json` (or `~/.claude.json`). The MCP server name MUST match the `server:<name>` suffix passed to Claude in step 3:
17
28
 
18
- ```jsonc
29
+ ```json
19
30
  {
20
31
  "mcpServers": {
21
- "cross-agent-teams": {
32
+ "cross-agent-teams-channel": {
22
33
  "command": "npx",
23
- "args": ["-y", "-p", "cross-agent-teams-mcp", "cross-agent-teams-channel"]
34
+ "args": [
35
+ "-y",
36
+ "cross-agent-teams-mcp@latest",
37
+ "cross-agent-teams-channel",
38
+ "--daemon-url",
39
+ "http://127.0.0.1:9100/mcp"
40
+ ]
24
41
  }
25
42
  }
26
43
  }
27
44
  ```
28
45
 
29
- When the channel CLI starts and its configured daemon URL points at `127.0.0.1` / `localhost`, it probes `<daemon-url>/health`; if the daemon is not running, it spawns a detached daemon child (the same `cross-agent-teams-mcp` bin), waits up to 5s for `/health` to return 200, and only then opens its MCP client connection. The auto-spawned daemon's stdout/stderr append to `~/.cross-agent-teams-mcp/daemon.log`.
46
+ If you started the daemon with `--token <t>`, set `CROSS_AGENT_TEAMS_MCP_DAEMON_URL` along with the appropriate header forwarding in your daemon-side configuration; the proxy itself reads only the daemon URL.
30
47
 
31
- If the URL is non-loopback (e.g. `http://10.0.0.5:9100/mcp`), auto-spawn is disabled the channel probes once and exits with a clear error if the daemon is unreachable.
48
+ For Codex CLI, see [docs/configs/codex-cli.md](docs/configs/codex-cli.md) Codex talks to the daemon directly over Streamable HTTP and does not need the channel proxy. For opencode see the "Using opencode with xats (tmux)" section below.
32
49
 
33
- If you prefer explicit lifecycle control or are developing locally, you can still build and start the daemon manually:
50
+ ### 3. Start Claude Code with the channel loader
34
51
 
35
52
  ```bash
36
- pnpm build
37
- node dist/cli.js daemon --port 9100
53
+ claude --dangerously-load-development-channels server:cross-agent-teams-channel
38
54
  ```
39
55
 
40
- The one-command local setup remains:
56
+ The `server:<name>` suffix MUST equal the MCP server name configured in `.mcp.json` (`cross-agent-teams-channel` above). If the names disagree, Claude Code's experimental channel loader will not wire the proxy in and you will not see channel wake notifications.
41
57
 
42
- ```bash
43
- ./start-server.sh
44
- ```
58
+ ### 4. Register from inside the agent
45
59
 
46
- The script runs `pnpm build` first, then starts the background services. To stop them:
60
+ Once Claude Code is connected through the proxy, register from inside the agent session see `register_claude_self`, `register_codex_self`, and `register_agent` below.
47
61
 
48
- ```bash
49
- ./stop-server.sh
50
- ```
62
+ ### Running from source
51
63
 
52
- Logs and pid files are written into `logs/`.
53
-
54
- ## Distribution
55
-
56
- This package publishes both `cross-agent-teams-mcp` (daemon) and `cross-agent-teams-channel` (Claude Code stdio proxy) bins from a single tarball. The recommended configuration is the single-entry stdio form above; the auto-daemon bootstrap means most users never need to start the daemon manually.
57
-
58
- Developers who want explicit lifecycle control still have `start-server.sh` / `stop-server.sh` (and the manual `node dist/cli.js daemon --port 9100`). Auto-daemon bootstrap is purely additive and does not change the daemon's HTTP API, MCP tool surface, or sqlite schema.
59
-
60
- ### Breaking change: bin and package rename
61
-
62
- The previous workspace plugin `cross-agent-teams-channel` (which exposed a bin named `cross-agent-teams-proxy`) is removed. Its code now lives inside `cross-agent-teams-mcp` as the `cross-agent-teams-channel` bin. Any external MCP config that references the legacy `cross-agent-teams-proxy` bin or a hard-coded path under `plugins/cross-agent-teams-channel/dist/` must update to the unified single-bin form above.
63
-
64
- The HTTP `cross-agent-teams-mcp` MCP entry continues to work for users who prefer the explicit two-entry form. The previous dual-entry config (HTTP daemon entry + stdio channel entry) still works unchanged; the HTTP entry simply duplicates what is now also reachable through the channel proxy.
65
-
66
- To run directly from source:
64
+ If you cloned this repo and want to run the daemon from source:
67
65
 
68
66
  ```bash
67
+ pnpm install
68
+ pnpm build
69
+ node dist/cli.js daemon --port 9100
70
+ # or, without a build step:
69
71
  npx tsx src/cli.ts daemon --port 9100
70
72
  ```
71
73
 
72
- By default the daemon listens on `127.0.0.1:9100`. The MCP endpoint is `http://127.0.0.1:9100/mcp`, and the health check endpoint is `http://127.0.0.1:9100/health`.
73
-
74
- You can verify the service with:
75
-
76
- ```bash
77
- curl http://127.0.0.1:9100/health
78
- ```
74
+ `./start-server.sh` / `./stop-server.sh` are local-development convenience scripts that also bring up a Codex app-server alongside the daemon; they are not needed when consuming this package via `npx`.
79
75
 
80
76
  ## Common Flags
81
77
 
package/README.zh-CN.md CHANGED
@@ -6,76 +6,72 @@
6
6
 
7
7
  ## 快速开始
8
8
 
9
- 本包提供两个 bin:
9
+ 这个包在同一个 npm 名字下发布两个 bin: 长驻的 HTTP daemon (`cross-agent-teams-mcp`) 以及一个 stdio channel proxy (`cross-agent-teams-channel`), 后者让 Claude Code 能从 daemon 收到 `notifications/channel_wake`. 你先在本机起一次 daemon, 然后让 Claude Code 把 proxy 当作 MCP server 接进来, 最后用 channel loader 启动 Claude 让它订阅 wake 通知. 没有"自动拉起 daemon" 这种行为 — proxy 不会, 也禁止替你启动 daemon; daemon 不可达时 proxy 直接 fail-fast 退出非零.
10
10
 
11
- - `cross-agent-teams-mcp` daemon (HTTP MCP server, 监听 `127.0.0.1:9100`).
12
- - `cross-agent-teams-channel` — Claude Code 用的 stdio MCP proxy. 启动时会自动 bootstrap daemon: 如果配置的 loopback URL 上没有健康的 daemon, 它会 spawn 一个 detached daemon 子进程, 等到 `/health` 返回 200 后再打开 MCP client。
11
+ ### 1. 启动 daemon
13
12
 
14
- channel 现在是一个完整的 stdio↔HTTP 透明代理: 客户端能看到 daemon 的全部工具表面 (`register_claude_self`, `list_agents`, `register_agent`, `bind_channel`, `send_message`, `task_*` 等), 同时保留 `notifications/claude/channel` wake 通知. 代理内部工具 `subscribe_channel_wake` 会从 host 可见列表中过滤掉.
13
+ ```bash
14
+ npx -y cross-agent-teams-mcp@latest daemon --port 9100
15
+ ```
16
+
17
+ 让这个进程保持运行 (单开一个终端 / `tmux` / `screen` / 你常用的进程守护工具均可). daemon 默认监听 `127.0.0.1:9100`. MCP endpoint 是 `http://127.0.0.1:9100/mcp`, 健康检查是 `http://127.0.0.1:9100/health`.
18
+
19
+ 启动后可以用下面命令确认服务正常:
20
+
21
+ ```bash
22
+ curl http://127.0.0.1:9100/health
23
+ ```
15
24
 
16
- 推荐的 Claude Code MCP config 折叠成单一 stdio entry, 不再需要手动起 daemon:
25
+ ### 2. 让 Claude Code 通过 channel proxy 连接
17
26
 
18
- ```jsonc
27
+ 在 `.mcp.json` (或 `~/.claude.json`) 里加上 channel proxy. MCP server 的 key **必须** 等于第 3 步给 Claude 的 `server:<name>` 后缀:
28
+
29
+ ```json
19
30
  {
20
31
  "mcpServers": {
21
- "cross-agent-teams": {
32
+ "cross-agent-teams-channel": {
22
33
  "command": "npx",
23
- "args": ["-y", "-p", "cross-agent-teams-mcp", "cross-agent-teams-channel"]
34
+ "args": [
35
+ "-y",
36
+ "cross-agent-teams-mcp@latest",
37
+ "cross-agent-teams-channel",
38
+ "--daemon-url",
39
+ "http://127.0.0.1:9100/mcp"
40
+ ]
24
41
  }
25
42
  }
26
43
  }
27
44
  ```
28
45
 
29
- channel CLI 启动时, 如果配置的 daemon URL 指向 `127.0.0.1` / `localhost`, 它会先去探测 `<daemon-url>/health`; 如果 daemon 没起来, spawn 一个 detached daemon child (同一个 `cross-agent-teams-mcp` bin), 最多等 5 秒等 `/health` 返回 200, 之后再打开 MCP client. 自动起来的 daemon 的 stdout/stderr 会 append 到 `~/.cross-agent-teams-mcp/daemon.log`.
46
+ 如果你在启动 daemon 时带了 `--token <t>`, 走环境变量 `CROSS_AGENT_TEAMS_MCP_DAEMON_URL` 配合 daemon 侧的 header 配置; proxy 自身只读取 daemon URL.
30
47
 
31
- 如果 URL 不是 loopback (比如 `http://10.0.0.5:9100/mcp`), 自动 spawn 会被禁用 channel 只探测一次, daemon 不可达就报错退出。
48
+ Codex CLI 的配置见 [docs/configs/codex-cli.md](docs/configs/codex-cli.md) Codex 直接用 Streamable HTTP daemon, 不需要 channel proxy. opencode 见下面的 "在 tmux 里使用 opencode" 章节.
32
49
 
33
- 如果你想显式控制 daemon 生命周期 (例如本地开发), 仍可以手动 build + 启动 daemon:
50
+ ### 3. channel loader 启动 Claude Code
34
51
 
35
52
  ```bash
36
- pnpm build
37
- node dist/cli.js daemon --port 9100
53
+ claude --dangerously-load-development-channels server:cross-agent-teams-channel
38
54
  ```
39
55
 
40
- 一键脚本仍然可用:
56
+ `server:<name>` 后缀 **必须** 与 `.mcp.json` 里 MCP server 的 key (上例中是 `cross-agent-teams-channel`) 完全一致. 名字不一致, Claude Code 的 experimental channel loader 不会把 proxy 接进来, 你也就收不到 channel wake 通知.
41
57
 
42
- ```bash
43
- ./start-server.sh
44
- ```
58
+ ### 4. 在 agent 里完成注册
45
59
 
46
- 这个脚本会先执行 `pnpm build`, 然后再启动后台服务. 停止服务可以用:
60
+ Claude Code 经由 proxy 连上之后, 在 agent 会话里调用 `register_claude_self` / `register_codex_self` / `register_agent` 完成注册, 详见后续章节.
47
61
 
48
- ```bash
49
- ./stop-server.sh
50
- ```
62
+ ### 从源码运行
51
63
 
52
- 日志和 pid 文件会写到 `logs/` 目录.
53
-
54
- ## 分发说明
55
-
56
- 本包同时发布 `cross-agent-teams-mcp` (daemon) 和 `cross-agent-teams-channel` (Claude Code stdio proxy) 两个 bin, 来自同一个 tarball. 推荐使用上面的单 entry stdio 配置; 由于有 auto-daemon bootstrap, 大多数使用者不需要手动起 daemon。
57
-
58
- 希望显式控制生命周期的开发者仍可以用 `start-server.sh` / `stop-server.sh` (或 `node dist/cli.js daemon --port 9100`). Auto-daemon bootstrap 是纯增量行为, 不改变 daemon 的 HTTP API, MCP tool 表面或 sqlite schema。
59
-
60
- ### Breaking change: bin 与 package 改名
61
-
62
- 之前的 workspace plugin `cross-agent-teams-channel` (它暴露的 bin 名为 `cross-agent-teams-proxy`) 已删除. 代码合并进了 `cross-agent-teams-mcp`, 作为新的 `cross-agent-teams-channel` bin. 所有外部 MCP config 中引用旧 bin 名 `cross-agent-teams-proxy`, 或硬编码 `plugins/cross-agent-teams-channel/dist/` 路径的位置, 都需要改成上面的统一单 bin 形式。
63
-
64
- 直接 HTTP 连接 `cross-agent-teams-mcp` 的 MCP entry 仍然可用, 适合喜欢显式两 entry 的用户. 之前的双 entry 配置 (HTTP daemon entry + stdio channel entry) 不变仍然可用; HTTP entry 只是重复了 channel 代理现在已经透传的工具表面。
65
-
66
- 如果你想直接跑源码, 可以用:
64
+ 如果你 clone 了仓库想直接跑源码:
67
65
 
68
66
  ```bash
67
+ pnpm install
68
+ pnpm build
69
+ node dist/cli.js daemon --port 9100
70
+ # 或者跳过 build:
69
71
  npx tsx src/cli.ts daemon --port 9100
70
72
  ```
71
73
 
72
- 服务默认监听 `127.0.0.1:9100`. MCP endpoint `http://127.0.0.1:9100/mcp`, 健康检查地址是 `http://127.0.0.1:9100/health`.
73
-
74
- 启动后可以用下面的命令确认服务正常:
75
-
76
- ```bash
77
- curl http://127.0.0.1:9100/health
78
- ```
74
+ `./start-server.sh` / `./stop-server.sh` 是本地开发用的便捷脚本, 会顺手把 Codex app-server 一起拉起来; 通过 `npx` 使用本包时不需要它们.
79
75
 
80
76
  ## 常用参数
81
77
 
@@ -1,24 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/channel-cli.ts
3
+ // plugins/cross-agent-teams-channel/src/cli.ts
4
4
  import { randomUUID } from "crypto";
5
- import { realpathSync } from "fs";
6
- import { fileURLToPath as fileURLToPath2 } from "url";
7
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
6
 
9
- // src/channel/proxy.ts
7
+ // plugins/cross-agent-teams-channel/src/proxy.ts
10
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
- import {
12
- CallToolRequestSchema,
13
- ErrorCode,
14
- ListToolsRequestSchema,
15
- McpError
16
- } from "@modelcontextprotocol/sdk/types.js";
17
- var PROXY_INTERNAL_TOOL = "subscribe_channel_wake";
18
9
  function createProxyServer() {
19
10
  return new McpServer(
20
11
  { name: "cross-agent-teams-channel", version: "0.1.0" },
21
- { capabilities: { tools: {}, experimental: { "claude/channel": {} } } }
12
+ { capabilities: { experimental: { "claude/channel": {} } } }
22
13
  );
23
14
  }
24
15
  function relayChannelWake(server, params) {
@@ -35,33 +26,8 @@ function relayChannelWake(server, params) {
35
26
  } catch {
36
27
  }
37
28
  }
38
- function installForwardingHandlers(server, getDaemonClient) {
39
- const inner = server.server;
40
- inner.setRequestHandler(ListToolsRequestSchema, async (request) => {
41
- const client = getDaemonClient();
42
- if (!client) {
43
- throw new McpError(ErrorCode.InternalError, "daemon not connected");
44
- }
45
- const result = await client.listTools(request.params);
46
- return {
47
- ...result,
48
- tools: result.tools.filter((t) => t.name !== PROXY_INTERNAL_TOOL)
49
- };
50
- });
51
- inner.setRequestHandler(CallToolRequestSchema, async (request) => {
52
- const name = request.params?.name;
53
- if (name === PROXY_INTERNAL_TOOL) {
54
- throw new McpError(ErrorCode.MethodNotFound, `tool not available: ${PROXY_INTERNAL_TOOL}`);
55
- }
56
- const client = getDaemonClient();
57
- if (!client) {
58
- throw new McpError(ErrorCode.InternalError, "daemon not connected");
59
- }
60
- return await client.callTool(request.params);
61
- });
62
- }
63
29
 
64
- // src/channel/daemon-client.ts
30
+ // plugins/cross-agent-teams-channel/src/daemon-client.ts
65
31
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
66
32
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
67
33
  async function parseToolResult(resp) {
@@ -77,7 +43,7 @@ async function parseToolResult(resp) {
77
43
  async function runRegistrationSequence(config) {
78
44
  const order = [];
79
45
  const transport = new StreamableHTTPClientTransport(new URL(config.daemonUrl));
80
- const client = new Client({ name: "cross-agent-teams-channel", version: "0.1.0" });
46
+ const client = new Client({ name: "cross-agent-teams-proxy", version: "0.1.0" });
81
47
  if (config.notificationHandler) {
82
48
  client.fallbackNotificationHandler = async (n) => {
83
49
  if (n.method === "notifications/channel_wake") {
@@ -133,7 +99,7 @@ async function runRegistrationSequence(config) {
133
99
  }
134
100
  };
135
101
  }
136
- async function runReconnectingProxy(config) {
102
+ function runReconnectingProxy(config) {
137
103
  let stopped = false;
138
104
  let currentSeq = null;
139
105
  async function waitForDisconnect(seq) {
@@ -158,44 +124,27 @@ async function runReconnectingProxy(config) {
158
124
  }
159
125
  }
160
126
  }
161
- const initialSeq = await runRegistrationSequence(config);
162
- currentSeq = initialSeq;
163
- if (config.onSequenceComplete) config.onSequenceComplete([...initialSeq.order]);
164
- async function backgroundLoop() {
127
+ async function loop() {
165
128
  while (!stopped) {
166
- const seq = currentSeq;
167
- if (!seq) {
168
- const wait2 = config.backoffInitialMs ?? 500;
169
- await new Promise((r) => setTimeout(r, wait2));
170
- if (stopped) break;
129
+ try {
130
+ const seq = await runRegistrationSequence(config);
131
+ currentSeq = seq;
132
+ if (config.onSequenceComplete) config.onSequenceComplete([...seq.order]);
133
+ await waitForDisconnect(seq);
134
+ if (config.onDisconnect) config.onDisconnect();
171
135
  try {
172
- const next = await runRegistrationSequence(config);
173
- currentSeq = next;
174
- if (config.onSequenceComplete) config.onSequenceComplete([...next.order]);
136
+ await seq.close();
175
137
  } catch {
176
138
  }
177
- continue;
178
- }
179
- await waitForDisconnect(seq);
180
- if (config.onDisconnect) config.onDisconnect();
181
- try {
182
- await seq.close();
139
+ currentSeq = null;
183
140
  } catch {
184
141
  }
185
- currentSeq = null;
186
142
  if (stopped) break;
187
143
  const wait = config.backoffInitialMs ?? 500;
188
144
  await new Promise((r) => setTimeout(r, wait));
189
- if (stopped) break;
190
- try {
191
- const next = await runRegistrationSequence(config);
192
- currentSeq = next;
193
- if (config.onSequenceComplete) config.onSequenceComplete([...next.order]);
194
- } catch {
195
- }
196
145
  }
197
146
  }
198
- void backgroundLoop();
147
+ void loop();
199
148
  return {
200
149
  stop: async () => {
201
150
  stopped = true;
@@ -205,101 +154,11 @@ async function runReconnectingProxy(config) {
205
154
  } catch {
206
155
  }
207
156
  }
208
- },
209
- getClient: () => currentSeq?.client ?? null
210
- };
211
- }
212
-
213
- // src/channel/auto-daemon.ts
214
- import { spawn } from "child_process";
215
- import { fileURLToPath } from "url";
216
- import { mkdirSync, openSync } from "fs";
217
- import { homedir } from "os";
218
- import { dirname, join } from "path";
219
- var DEFAULT_LOG_PATH = join(homedir(), ".cross-agent-teams-mcp", "daemon.log");
220
- function isLoopbackHost(host) {
221
- return host === "127.0.0.1" || host === "localhost";
222
- }
223
- function originOf(url) {
224
- return `${url.protocol}//${url.host}`;
225
- }
226
- async function probeHealth(origin, timeoutMs, fetchImpl) {
227
- const ac = new AbortController();
228
- const timer = setTimeout(() => ac.abort(), timeoutMs);
229
- try {
230
- const resp = await fetchImpl(`${origin}/health`, { signal: ac.signal });
231
- return resp.status >= 200 && resp.status < 300;
232
- } catch {
233
- return false;
234
- } finally {
235
- clearTimeout(timer);
236
- }
237
- }
238
- function resolveDaemonEntry() {
239
- const override = process.env.CROSS_AGENT_TEAMS_CHANNEL_DAEMON_ENTRY;
240
- if (override && override.length > 0) return override;
241
- return fileURLToPath(new URL("./cli.js", import.meta.url));
242
- }
243
- function ensureLogDir(logPath) {
244
- try {
245
- mkdirSync(dirname(logPath), { recursive: true });
246
- } catch {
247
- }
248
- }
249
- async function ensureDaemonHealthy(opts) {
250
- const fetchImpl = opts.fetchImpl ?? fetch;
251
- const spawnImpl = opts.spawnImpl ?? spawn;
252
- const probeTimeoutMs = opts.probeTimeoutMs ?? 2e3;
253
- const pollAttempts = opts.pollAttempts ?? 20;
254
- const pollIntervalMs = opts.pollIntervalMs ?? 250;
255
- const logFilePath = opts.logFilePath ?? DEFAULT_LOG_PATH;
256
- let parsed;
257
- try {
258
- parsed = new URL(opts.daemonUrl);
259
- } catch {
260
- throw new Error(`invalid daemon URL: ${opts.daemonUrl}`);
261
- }
262
- const origin = originOf(parsed);
263
- const host = parsed.hostname;
264
- const port = parsed.port ? Number(parsed.port) : null;
265
- if (await probeHealth(origin, probeTimeoutMs, fetchImpl)) return;
266
- if (!isLoopbackHost(host)) {
267
- throw new Error(
268
- `daemon at ${opts.daemonUrl} not reachable; auto-spawn disabled for non-loopback URLs`
269
- );
270
- }
271
- const entry = opts.daemonEntryOverride ?? resolveDaemonEntry();
272
- const args = ["daemon"];
273
- if (port !== null && Number.isFinite(port)) {
274
- args.push("--port", String(port));
275
- }
276
- ensureLogDir(logFilePath);
277
- let logFd;
278
- try {
279
- logFd = openSync(logFilePath, "a");
280
- } catch {
281
- logFd = void 0;
282
- }
283
- const spawnOptions = {
284
- detached: true,
285
- stdio: logFd !== void 0 ? ["ignore", logFd, logFd] : "ignore"
157
+ }
286
158
  };
287
- const child = spawnImpl(process.execPath, [entry, ...args], spawnOptions);
288
- try {
289
- child.unref();
290
- } catch {
291
- }
292
- for (let i = 0; i < pollAttempts; i++) {
293
- await new Promise((r) => setTimeout(r, pollIntervalMs));
294
- if (await probeHealth(origin, probeTimeoutMs, fetchImpl)) return;
295
- }
296
- throw new Error(
297
- `daemon failed to become healthy at ${opts.daemonUrl} within bootstrap deadline; see log at ${logFilePath}`
298
- );
299
159
  }
300
160
 
301
- // src/channel-cli.ts
302
- var DEFAULT_DAEMON_URL = "http://127.0.0.1:9100/mcp";
161
+ // plugins/cross-agent-teams-channel/src/cli.ts
303
162
  var CliArgError = class extends Error {
304
163
  constructor(message) {
305
164
  super(message);
@@ -309,11 +168,8 @@ var CliArgError = class extends Error {
309
168
  function buildStartupHint(csid) {
310
169
  const content = [
311
170
  `cross-agent-teams-mcp: your channel_session_id is ${csid}.`,
312
- `You are connected to the cross-agent-teams (xats) collaboration server but NOT yet registered as an agent. Before doing other work in this session, ask the user with this exact English prompt (verbatim):`,
313
- `"To collaborate via xats, please tell me: register to xats, name: <preferred-name>, team: <preferred-team>"`,
314
- `When the user replies, call list_agents first to verify the chosen name is not already in use within that team; if it is taken, ask the user for a different name.`,
315
- `Once you have a unique name, call register_claude_self({name, team, ui_pid: $PPID}) to complete registration. ui_pid is the Claude Code CLI parent process pid (run \`echo $PPID\` from a Bash tool to obtain it). Do NOT pass channel_session_id; the daemon auto-binds via ui_pid.`,
316
- `The unified equivalent register_agent({client: "claude-code", name, team, model, ui_pid: $PPID}) also works without channel_session_id.`,
171
+ `Preferred in Claude Code: call register_claude_self({name: "<agent-name>", ui_pid: $PPID}) from this session \u2014 do NOT pass channel_session_id here; the daemon auto-binds via ui_pid.`,
172
+ `Unified equivalent: register_agent({client: "claude-code", name: "<agent-name>", model: "<model>", ui_pid: $PPID}) \u2014 also without channel_session_id.`,
317
173
  `bind_channel({channel_session_id: "${csid}"}) is the low-level rebind tool for an already-registered Claude host that needs to switch to a fresh csid; it is NOT the primary registration path.`,
318
174
  `Do not use curl or another external HTTP client for Claude registration here \u2014 that would create a different MCP session, and follow-up tools in Claude Code could still see unknown_agent.`
319
175
  ].join(" ");
@@ -340,7 +196,9 @@ function parseCliArgs(argv, env = process.env) {
340
196
  daemonUrl = env.CROSS_AGENT_TEAMS_MCP_DAEMON_URL;
341
197
  }
342
198
  if (!daemonUrl || daemonUrl.length === 0) {
343
- daemonUrl = DEFAULT_DAEMON_URL;
199
+ throw new CliArgError(
200
+ "missing --daemon-url (or CROSS_AGENT_TEAMS_MCP_DAEMON_URL env var)"
201
+ );
344
202
  }
345
203
  return { daemonUrl };
346
204
  }
@@ -350,45 +208,26 @@ async function main(argv = process.argv.slice(2), env = process.env) {
350
208
  args = parseCliArgs(argv, env);
351
209
  } catch (err) {
352
210
  const msg = err instanceof Error ? err.message : String(err);
353
- process.stderr.write(`cross-agent-teams-channel: ${msg}
211
+ process.stderr.write(`cross-agent-teams-proxy: ${msg}
354
212
  `);
355
213
  process.exit(2);
356
214
  }
357
215
  const csid = randomUUID();
358
- try {
359
- await ensureDaemonHealthy({ daemonUrl: args.daemonUrl, log: process.stderr });
360
- } catch (err) {
361
- const msg = err instanceof Error ? err.message : String(err);
362
- process.stderr.write(`cross-agent-teams-channel: ${msg}
363
- `);
364
- process.exit(2);
365
- }
366
216
  const hostServer = createProxyServer();
367
217
  const stdioTransport = new StdioServerTransport();
368
- let controller;
369
- try {
370
- controller = await runReconnectingProxy({
371
- daemonUrl: args.daemonUrl,
372
- channel_session_id: csid,
373
- notificationHandler: (params) => {
374
- relayChannelWake(hostServer, params);
375
- }
376
- // No onSequenceComplete: the startup hint is emitted from
377
- // hostServer.server.oninitialized below (fires after the host's
378
- // MCP initialize handshake completes). Reconnects do not re-emit
379
- // because csid is stable across reconnects and the host has long
380
- // since seen it.
381
- });
382
- } catch (err) {
383
- const msg = err instanceof Error ? err.message : String(err);
384
- process.stderr.write(`cross-agent-teams-channel: initial daemon registration failed: ${msg}
385
- `);
386
- process.exit(2);
387
- }
388
- installForwardingHandlers(hostServer, () => controller.getClient());
389
- hostServer.server.oninitialized = () => {
390
- relayChannelWake(hostServer, buildStartupHint(csid));
391
- };
218
+ let registrationEverSucceeded = false;
219
+ const controller = runReconnectingProxy({
220
+ daemonUrl: args.daemonUrl,
221
+ channel_session_id: csid,
222
+ notificationHandler: (params) => {
223
+ relayChannelWake(hostServer, params);
224
+ },
225
+ onSequenceComplete: () => {
226
+ registrationEverSucceeded = true;
227
+ const hint = buildStartupHint(csid);
228
+ relayChannelWake(hostServer, hint);
229
+ }
230
+ });
392
231
  let stopped = false;
393
232
  const shutdown = async () => {
394
233
  if (stopped) return;
@@ -401,6 +240,11 @@ async function main(argv = process.argv.slice(2), env = process.env) {
401
240
  await hostServer.close();
402
241
  } catch {
403
242
  }
243
+ if (!registrationEverSucceeded) {
244
+ process.stderr.write(`cross-agent-teams-proxy: daemon unreachable at ${args.daemonUrl}
245
+ `);
246
+ process.exit(1);
247
+ }
404
248
  process.exit(0);
405
249
  };
406
250
  stdioTransport.onclose = () => {
@@ -414,21 +258,8 @@ async function main(argv = process.argv.slice(2), env = process.env) {
414
258
  void shutdown();
415
259
  });
416
260
  }
417
- function isInvokedAsEntry() {
418
- const argv1 = process.argv[1];
419
- if (!argv1) return false;
420
- try {
421
- return realpathSync(argv1) === fileURLToPath2(import.meta.url);
422
- } catch {
423
- return false;
424
- }
425
- }
426
- if (isInvokedAsEntry()) {
427
- main().catch((e) => {
428
- process.stderr.write(`cross-agent-teams-channel: ${e?.message ?? e}
429
- `);
430
- process.exit(1);
431
- });
261
+ if (import.meta.url === `file://${process.argv[1]}`) {
262
+ void main();
432
263
  }
433
264
  export {
434
265
  CliArgError,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/channel-cli.ts","../src/channel/proxy.ts","../src/channel/daemon-client.ts","../src/channel/auto-daemon.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { randomUUID } from 'node:crypto'\nimport { realpathSync } from 'node:fs'\nimport { fileURLToPath } from 'node:url'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport { createProxyServer, installForwardingHandlers, relayChannelWake } from './channel/proxy.js'\nimport { runReconnectingProxy } from './channel/daemon-client.js'\nimport { ensureDaemonHealthy } from './channel/auto-daemon.js'\n\nconst DEFAULT_DAEMON_URL = 'http://127.0.0.1:9100/mcp'\n\ninterface CliArgs {\n daemonUrl: string\n}\n\nexport class CliArgError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'CliArgError'\n }\n}\n\nexport function buildStartupHint(csid: string): { content: string; meta: { source: string; kind: string } } {\n const content = [\n `cross-agent-teams-mcp: your channel_session_id is ${csid}.`,\n `You are connected to the cross-agent-teams (xats) collaboration server but NOT yet registered as an agent. Before doing other work in this session, ask the user with this exact English prompt (verbatim):`,\n `\"To collaborate via xats, please tell me: register to xats, name: <preferred-name>, team: <preferred-team>\"`,\n `When the user replies, call list_agents first to verify the chosen name is not already in use within that team; if it is taken, ask the user for a different name.`,\n `Once you have a unique name, call register_claude_self({name, team, ui_pid: $PPID}) to complete registration. ui_pid is the Claude Code CLI parent process pid (run \\`echo $PPID\\` from a Bash tool to obtain it). Do NOT pass channel_session_id; the daemon auto-binds via ui_pid.`,\n `The unified equivalent register_agent({client: \"claude-code\", name, team, model, ui_pid: $PPID}) also works without channel_session_id.`,\n `bind_channel({channel_session_id: \"${csid}\"}) is the low-level rebind tool for an already-registered Claude host that needs to switch to a fresh csid; it is NOT the primary registration path.`,\n `Do not use curl or another external HTTP client for Claude registration here — that would create a different MCP session, and follow-up tools in Claude Code could still see unknown_agent.`\n ].join(' ')\n return {\n content,\n meta: { source: 'cross_agent_teams_mcp', kind: 'startup_bind_hint' }\n }\n}\n\nexport function parseCliArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): CliArgs {\n let daemonUrl: string | undefined\n\n for (let i = 0; i < argv.length; i++) {\n const flag = argv[i]\n const next = argv[i + 1]\n switch (flag) {\n case '--daemon-url':\n daemonUrl = next; i++; break\n default:\n // Ignore unknown flags for forward-compat (including legacy\n // --agent-team / --agent-name, which are no longer honored).\n break\n }\n }\n\n if (!daemonUrl || daemonUrl.length === 0) {\n daemonUrl = env.CROSS_AGENT_TEAMS_MCP_DAEMON_URL\n }\n\n if (!daemonUrl || daemonUrl.length === 0) {\n daemonUrl = DEFAULT_DAEMON_URL\n }\n return { daemonUrl }\n}\n\nexport async function main(\n argv: readonly string[] = process.argv.slice(2),\n env: NodeJS.ProcessEnv = process.env\n): Promise<void> {\n let args: CliArgs\n try {\n args = parseCliArgs(argv, env)\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n process.stderr.write(`cross-agent-teams-channel: ${msg}\\n`)\n process.exit(2)\n }\n\n // Fresh csid per startup — no persistence. Multi-instance safe.\n const csid = randomUUID()\n\n try {\n await ensureDaemonHealthy({ daemonUrl: args.daemonUrl, log: process.stderr })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n process.stderr.write(`cross-agent-teams-channel: ${msg}\\n`)\n process.exit(2)\n }\n\n const hostServer = createProxyServer()\n const stdioTransport = new StdioServerTransport()\n\n // Await the first registration before connecting stdio so the proxy's\n // forwarding handlers see a live daemon-facing client on the very first\n // host request. Without this, a host that issues tools/list immediately\n // after initialize can race the background register/subscribe and observe\n // an empty / errored tool list which Claude Code caches for the session.\n let controller: Awaited<ReturnType<typeof runReconnectingProxy>>\n try {\n controller = await runReconnectingProxy({\n daemonUrl: args.daemonUrl,\n channel_session_id: csid,\n notificationHandler: (params) => {\n relayChannelWake(hostServer, params as { content: string; meta: Record<string, string> })\n }\n // No onSequenceComplete: the startup hint is emitted from\n // hostServer.server.oninitialized below (fires after the host's\n // MCP initialize handshake completes). Reconnects do not re-emit\n // because csid is stable across reconnects and the host has long\n // since seen it.\n })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n process.stderr.write(`cross-agent-teams-channel: initial daemon registration failed: ${msg}\\n`)\n process.exit(2)\n }\n\n installForwardingHandlers(hostServer, () => controller.getClient())\n\n // Emit the startup hint after the host has completed its MCP initialize\n // handshake (i.e., sent `notifications/initialized`). Sending earlier\n // risks Claude Code dropping the notification because the experimental\n // `claude/channel` capability hasn't been negotiated yet on its side.\n hostServer.server.oninitialized = () => {\n relayChannelWake(hostServer, buildStartupHint(csid))\n }\n\n let stopped = false\n const shutdown = async (): Promise<void> => {\n if (stopped) return\n stopped = true\n try { await controller.stop() } catch { /* best-effort */ }\n try { await hostServer.close() } catch { /* best-effort */ }\n process.exit(0)\n }\n\n stdioTransport.onclose = () => { void shutdown() }\n\n await hostServer.connect(stdioTransport)\n // The initial startup hint is emitted from the oninitialized hook above,\n // which fires after the host completes its MCP initialize handshake.\n\n process.on('SIGTERM', () => { void shutdown() })\n process.on('SIGINT', () => { void shutdown() })\n}\n\nfunction isInvokedAsEntry(): boolean {\n const argv1 = process.argv[1]\n if (!argv1) return false\n try {\n return realpathSync(argv1) === fileURLToPath(import.meta.url)\n } catch {\n return false\n }\n}\n\nif (isInvokedAsEntry()) {\n main().catch((e) => {\n process.stderr.write(`cross-agent-teams-channel: ${e?.message ?? e}\\n`)\n process.exit(1)\n })\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { Client } from '@modelcontextprotocol/sdk/client/index.js'\nimport {\n CallToolRequestSchema,\n ErrorCode,\n ListToolsRequestSchema,\n McpError\n} from '@modelcontextprotocol/sdk/types.js'\n\nconst PROXY_INTERNAL_TOOL = 'subscribe_channel_wake'\n\nexport function createProxyServer(): McpServer {\n return new McpServer(\n { name: 'cross-agent-teams-channel', version: '0.1.0' },\n { capabilities: { tools: {}, experimental: { 'claude/channel': {} } } }\n )\n}\n\nexport interface ChannelWakeParams {\n content: string\n meta: Record<string, string>\n}\n\nexport function relayChannelWake(server: McpServer, params: ChannelWakeParams): void {\n try {\n const notif = {\n method: 'notifications/claude/channel',\n params: params as unknown as Record<string, unknown>\n }\n const p = (server.server.notification as (n: typeof notif) => Promise<void>)(notif)\n if (p && typeof p.catch === 'function') {\n p.catch(() => { /* host closed — drop silently */ })\n }\n } catch {\n // host transport closed or not yet connected — drop silently\n }\n}\n\nexport type GetDaemonClient = () => Client | null\n\nexport function installForwardingHandlers(\n server: McpServer,\n getDaemonClient: GetDaemonClient\n): void {\n const inner = server.server\n\n inner.setRequestHandler(ListToolsRequestSchema, async (request) => {\n const client = getDaemonClient()\n if (!client) {\n throw new McpError(ErrorCode.InternalError, 'daemon not connected')\n }\n const result = await client.listTools(request.params)\n return {\n ...result,\n tools: result.tools.filter((t) => t.name !== PROXY_INTERNAL_TOOL)\n }\n })\n\n inner.setRequestHandler(CallToolRequestSchema, async (request) => {\n const name = request.params?.name\n if (name === PROXY_INTERNAL_TOOL) {\n throw new McpError(ErrorCode.MethodNotFound, `tool not available: ${PROXY_INTERNAL_TOOL}`)\n }\n const client = getDaemonClient()\n if (!client) {\n throw new McpError(ErrorCode.InternalError, 'daemon not connected')\n }\n return await client.callTool(request.params)\n })\n}\n","import { Client } from '@modelcontextprotocol/sdk/client/index.js'\nimport { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'\n\nexport interface RegistrationConfig {\n daemonUrl: string\n channel_session_id: string\n backoffInitialMs?: number\n backoffMaxMs?: number\n notificationHandler?: (payload: unknown) => void\n}\n\nexport interface ReconnectingProxyConfig extends RegistrationConfig {\n onSequenceComplete?: (order: string[]) => void\n onDisconnect?: () => void\n healthCheckIntervalMs?: number\n}\n\nexport interface ReconnectingProxyController {\n stop(): Promise<void>\n getClient(): Client | null\n}\n\nexport interface RegistrationSequenceResult {\n order: string[]\n lastSubscribeResult: unknown\n client: Client\n transport: StreamableHTTPClientTransport\n close: () => Promise<void>\n}\n\ntype ToolResult = Record<string, unknown>\n\nasync function parseToolResult(resp: unknown): Promise<ToolResult> {\n const r = resp as { content?: Array<{ text?: string }> }\n const text = r.content?.[0]?.text\n if (typeof text !== 'string') return {}\n try { return JSON.parse(text) as ToolResult } catch { return {} }\n}\n\nexport async function runRegistrationSequence(\n config: RegistrationConfig\n): Promise<RegistrationSequenceResult> {\n const order: string[] = []\n const transport = new StreamableHTTPClientTransport(new URL(config.daemonUrl))\n const client = new Client({ name: 'cross-agent-teams-channel', version: '0.1.0' })\n\n if (config.notificationHandler) {\n client.fallbackNotificationHandler = async (n) => {\n if (n.method === 'notifications/channel_wake') {\n config.notificationHandler!(n.params)\n }\n }\n }\n\n await client.connect(transport)\n\n // 1. register_agent as proxy — identity keyed on pid, stable across reconnects\n // so the (team, name) ON CONFLICT upsert reuses the same row instead of spamming new rows\n const registerResp = await client.callTool({\n name: 'register_agent',\n arguments: {\n client: 'custom',\n client_name: 'cross-agent-teams-channel',\n model: 'proxy',\n role: '__channel_proxy__',\n name: `channel-proxy-${process.pid}`,\n team: 'default',\n claude_ui_pid: process.ppid,\n delivery: {\n kind: 'claude-channel',\n channel_session_id: config.channel_session_id,\n },\n }\n })\n order.push('register_agent')\n const regResult = await parseToolResult(registerResp)\n if (!('agent_id' in regResult)) {\n throw new Error(`register_agent failed: ${JSON.stringify(regResult)}`)\n }\n\n // 2. subscribe_channel_wake — proxy's csid is fresh per startup\n const subResp = await client.callTool({\n name: 'subscribe_channel_wake',\n arguments: { channel_session_id: config.channel_session_id }\n })\n order.push('subscribe_channel_wake')\n const subResult = await parseToolResult(subResp)\n if (!('ok' in subResult) || subResult.ok !== true) {\n throw new Error(`subscribe_channel_wake failed: ${JSON.stringify(subResult)}`)\n }\n\n return {\n order,\n lastSubscribeResult: subResult,\n client,\n transport,\n close: async () => {\n try { await client.close() } catch { /* best-effort */ }\n try { await transport.close() } catch { /* best-effort */ }\n }\n }\n}\n\nexport async function runReconnectingProxy(config: ReconnectingProxyConfig): Promise<ReconnectingProxyController> {\n let stopped = false\n let currentSeq: RegistrationSequenceResult | null = null\n\n async function waitForDisconnect(seq: RegistrationSequenceResult): Promise<void> {\n const interval = config.healthCheckIntervalMs ?? 200\n let disconnected = false\n const closeHandler = () => { disconnected = true }\n const prevOnClose = seq.transport.onclose\n seq.transport.onclose = () => { prevOnClose?.(); closeHandler() }\n while (!disconnected && !stopped) {\n await new Promise(r => setTimeout(r, interval))\n if (disconnected || stopped) break\n try {\n await seq.client.callTool({ name: 'echo', arguments: { msg: 'hb' } })\n } catch {\n disconnected = true\n break\n }\n }\n }\n\n // First registration is awaited synchronously so callers can rely on\n // getClient() returning a live Client immediately after this resolves.\n // This eliminates a cold-start race where forwarded tools/list / tools/call\n // requests from a host (e.g. Claude Code) could arrive before the\n // daemon-facing client finished registering.\n const initialSeq = await runRegistrationSequence(config)\n currentSeq = initialSeq\n if (config.onSequenceComplete) config.onSequenceComplete([...initialSeq.order])\n\n async function backgroundLoop(): Promise<void> {\n while (!stopped) {\n const seq = currentSeq\n if (!seq) {\n // currentSeq cleared by a prior failed reconnect; wait and try again.\n const wait = config.backoffInitialMs ?? 500\n await new Promise(r => setTimeout(r, wait))\n if (stopped) break\n try {\n const next = await runRegistrationSequence(config)\n currentSeq = next\n if (config.onSequenceComplete) config.onSequenceComplete([...next.order])\n } catch {\n // try again next iteration\n }\n continue\n }\n\n await waitForDisconnect(seq)\n if (config.onDisconnect) config.onDisconnect()\n try { await seq.close() } catch { /* best-effort */ }\n currentSeq = null\n if (stopped) break\n\n const wait = config.backoffInitialMs ?? 500\n await new Promise(r => setTimeout(r, wait))\n if (stopped) break\n\n try {\n const next = await runRegistrationSequence(config)\n currentSeq = next\n if (config.onSequenceComplete) config.onSequenceComplete([...next.order])\n } catch {\n // currentSeq stays null; loop will retry after backoff.\n }\n }\n }\n\n void backgroundLoop()\n\n return {\n stop: async () => {\n stopped = true\n if (currentSeq) {\n try { await currentSeq.close() } catch { /* best-effort */ }\n }\n },\n getClient: () => currentSeq?.client ?? null\n }\n}\n","import { spawn, type SpawnOptions } from 'node:child_process'\nimport { fileURLToPath } from 'node:url'\nimport { mkdirSync, openSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { dirname, join } from 'node:path'\n\nexport interface EnsureDaemonHealthyOptions {\n daemonUrl: string\n log?: NodeJS.WritableStream\n // Test-only injection points; production callers leave these undefined.\n fetchImpl?: typeof fetch\n spawnImpl?: typeof spawn\n daemonEntryOverride?: string\n probeTimeoutMs?: number\n pollAttempts?: number\n pollIntervalMs?: number\n logFilePath?: string\n}\n\nconst DEFAULT_LOG_PATH = join(homedir(), '.cross-agent-teams-mcp', 'daemon.log')\n\nfunction isLoopbackHost(host: string): boolean {\n return host === '127.0.0.1' || host === 'localhost'\n}\n\nfunction originOf(url: URL): string {\n return `${url.protocol}//${url.host}`\n}\n\nasync function probeHealth(\n origin: string,\n timeoutMs: number,\n fetchImpl: typeof fetch\n): Promise<boolean> {\n const ac = new AbortController()\n const timer = setTimeout(() => ac.abort(), timeoutMs)\n try {\n const resp = await fetchImpl(`${origin}/health`, { signal: ac.signal })\n return resp.status >= 200 && resp.status < 300\n } catch {\n return false\n } finally {\n clearTimeout(timer)\n }\n}\n\nfunction resolveDaemonEntry(): string {\n // Test escape hatch: when set, tests can inject a fake daemon entry without\n // having to mock child_process.spawn inside a subprocess.\n const override = process.env.CROSS_AGENT_TEAMS_CHANNEL_DAEMON_ENTRY\n if (override && override.length > 0) return override\n // After tsup bundles, this module is inlined into dist/channel-cli.js;\n // the daemon entry sits next to it as dist/cli.js. Resolving relative to\n // import.meta.url (which equals the channel-cli.js URL post-build) gives\n // the right neighbor file.\n return fileURLToPath(new URL('./cli.js', import.meta.url))\n}\n\nfunction ensureLogDir(logPath: string): void {\n try {\n mkdirSync(dirname(logPath), { recursive: true })\n } catch {\n // best-effort — spawn will fail clearly if path still unusable\n }\n}\n\nexport async function ensureDaemonHealthy(opts: EnsureDaemonHealthyOptions): Promise<void> {\n const fetchImpl = opts.fetchImpl ?? fetch\n const spawnImpl = opts.spawnImpl ?? spawn\n const probeTimeoutMs = opts.probeTimeoutMs ?? 2000\n const pollAttempts = opts.pollAttempts ?? 20\n const pollIntervalMs = opts.pollIntervalMs ?? 250\n const logFilePath = opts.logFilePath ?? DEFAULT_LOG_PATH\n\n let parsed: URL\n try {\n parsed = new URL(opts.daemonUrl)\n } catch {\n throw new Error(`invalid daemon URL: ${opts.daemonUrl}`)\n }\n\n const origin = originOf(parsed)\n const host = parsed.hostname\n const port = parsed.port ? Number(parsed.port) : null\n\n // Step 1: probe.\n if (await probeHealth(origin, probeTimeoutMs, fetchImpl)) return\n\n // Step 2: non-loopback → no spawn, error out.\n if (!isLoopbackHost(host)) {\n throw new Error(\n `daemon at ${opts.daemonUrl} not reachable; auto-spawn disabled for non-loopback URLs`\n )\n }\n\n // Step 3: spawn detached daemon.\n const entry = opts.daemonEntryOverride ?? resolveDaemonEntry()\n const args = ['daemon']\n if (port !== null && Number.isFinite(port)) {\n args.push('--port', String(port))\n }\n\n ensureLogDir(logFilePath)\n let logFd: number | undefined\n try {\n logFd = openSync(logFilePath, 'a')\n } catch {\n logFd = undefined\n }\n\n const spawnOptions: SpawnOptions = {\n detached: true,\n stdio: logFd !== undefined\n ? ['ignore', logFd, logFd]\n : 'ignore'\n }\n const child = spawnImpl(process.execPath, [entry, ...args], spawnOptions)\n try { child.unref() } catch { /* best-effort */ }\n\n // Step 4: poll /health.\n for (let i = 0; i < pollAttempts; i++) {\n await new Promise(r => setTimeout(r, pollIntervalMs))\n if (await probeHealth(origin, probeTimeoutMs, fetchImpl)) return\n }\n\n throw new Error(\n `daemon failed to become healthy at ${opts.daemonUrl} within bootstrap deadline; ` +\n `see log at ${logFilePath}`\n )\n}\n"],"mappings":"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","names":["fileURLToPath","wait","fileURLToPath"]}
1
+ {"version":3,"sources":["../plugins/cross-agent-teams-channel/src/cli.ts","../plugins/cross-agent-teams-channel/src/proxy.ts","../plugins/cross-agent-teams-channel/src/daemon-client.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { randomUUID } from 'node:crypto'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport { createProxyServer, relayChannelWake } from './proxy.js'\nimport { runReconnectingProxy } from './daemon-client.js'\n\ninterface CliArgs {\n daemonUrl: string\n}\n\nexport class CliArgError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'CliArgError'\n }\n}\n\nexport function buildStartupHint(csid: string): { content: string; meta: { source: string; kind: string } } {\n const content = [\n `cross-agent-teams-mcp: your channel_session_id is ${csid}.`,\n `Preferred in Claude Code: call register_claude_self({name: \"<agent-name>\", ui_pid: $PPID}) from this session — do NOT pass channel_session_id here; the daemon auto-binds via ui_pid.`,\n `Unified equivalent: register_agent({client: \"claude-code\", name: \"<agent-name>\", model: \"<model>\", ui_pid: $PPID}) — also without channel_session_id.`,\n `bind_channel({channel_session_id: \"${csid}\"}) is the low-level rebind tool for an already-registered Claude host that needs to switch to a fresh csid; it is NOT the primary registration path.`,\n `Do not use curl or another external HTTP client for Claude registration here — that would create a different MCP session, and follow-up tools in Claude Code could still see unknown_agent.`\n ].join(' ')\n return {\n content,\n meta: { source: 'cross_agent_teams_mcp', kind: 'startup_bind_hint' }\n }\n}\n\nexport function parseCliArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): CliArgs {\n let daemonUrl: string | undefined\n\n for (let i = 0; i < argv.length; i++) {\n const flag = argv[i]\n const next = argv[i + 1]\n switch (flag) {\n case '--daemon-url':\n daemonUrl = next; i++; break\n default:\n // Ignore unknown flags for forward-compat (including legacy\n // --agent-team / --agent-name, which are no longer honored).\n break\n }\n }\n\n if (!daemonUrl || daemonUrl.length === 0) {\n daemonUrl = env.CROSS_AGENT_TEAMS_MCP_DAEMON_URL\n }\n\n if (!daemonUrl || daemonUrl.length === 0) {\n throw new CliArgError(\n 'missing --daemon-url (or CROSS_AGENT_TEAMS_MCP_DAEMON_URL env var)'\n )\n }\n return { daemonUrl }\n}\n\nexport async function main(\n argv: readonly string[] = process.argv.slice(2),\n env: NodeJS.ProcessEnv = process.env\n): Promise<void> {\n let args: CliArgs\n try {\n args = parseCliArgs(argv, env)\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n process.stderr.write(`cross-agent-teams-proxy: ${msg}\\n`)\n process.exit(2)\n }\n\n // Fresh csid per startup — no persistence. Multi-instance safe.\n const csid = randomUUID()\n\n const hostServer = createProxyServer()\n const stdioTransport = new StdioServerTransport()\n\n let registrationEverSucceeded = false\n const controller = runReconnectingProxy({\n daemonUrl: args.daemonUrl,\n channel_session_id: csid,\n notificationHandler: (params) => {\n relayChannelWake(hostServer, params as { content: string; meta: Record<string, string> })\n },\n onSequenceComplete: () => {\n registrationEverSucceeded = true\n // Announce csid to Claude via host-facing channel notification so Claude\n // can call bind_channel({channel_session_id}) to bind its own agent row.\n const hint = buildStartupHint(csid)\n relayChannelWake(hostServer, hint)\n }\n })\n\n let stopped = false\n const shutdown = async (): Promise<void> => {\n if (stopped) return\n stopped = true\n try { await controller.stop() } catch { /* best-effort */ }\n try { await hostServer.close() } catch { /* best-effort */ }\n if (!registrationEverSucceeded) {\n process.stderr.write(`cross-agent-teams-proxy: daemon unreachable at ${args.daemonUrl}\\n`)\n process.exit(1)\n }\n process.exit(0)\n }\n\n stdioTransport.onclose = () => { void shutdown() }\n\n await hostServer.connect(stdioTransport)\n\n process.on('SIGTERM', () => { void shutdown() })\n process.on('SIGINT', () => { void shutdown() })\n}\n\n// eslint-disable-next-line @typescript-eslint/no-misused-promises\nif (import.meta.url === `file://${process.argv[1]}`) {\n void main()\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\n\nexport function createProxyServer(): McpServer {\n return new McpServer(\n { name: 'cross-agent-teams-channel', version: '0.1.0' },\n { capabilities: { experimental: { 'claude/channel': {} } } }\n )\n}\n\nexport interface ChannelWakeParams {\n content: string\n meta: Record<string, string>\n}\n\nexport function relayChannelWake(server: McpServer, params: ChannelWakeParams): void {\n try {\n const notif = {\n method: 'notifications/claude/channel',\n params: params as unknown as Record<string, unknown>\n }\n const p = (server.server.notification as (n: typeof notif) => Promise<void>)(notif)\n if (p && typeof p.catch === 'function') {\n p.catch(() => { /* host closed — drop silently */ })\n }\n } catch {\n // host transport closed or not yet connected — drop silently\n }\n}\n","import { Client } from '@modelcontextprotocol/sdk/client/index.js'\nimport { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'\n\nexport interface RegistrationConfig {\n daemonUrl: string\n channel_session_id: string\n backoffInitialMs?: number\n backoffMaxMs?: number\n notificationHandler?: (payload: unknown) => void\n}\n\nexport interface ReconnectingProxyConfig extends RegistrationConfig {\n onSequenceComplete?: (order: string[]) => void\n onDisconnect?: () => void\n healthCheckIntervalMs?: number\n}\n\nexport interface ReconnectingProxyController {\n stop(): Promise<void>\n}\n\nexport interface RegistrationSequenceResult {\n order: string[]\n lastSubscribeResult: unknown\n client: Client\n transport: StreamableHTTPClientTransport\n close: () => Promise<void>\n}\n\ntype ToolResult = Record<string, unknown>\n\nasync function parseToolResult(resp: unknown): Promise<ToolResult> {\n const r = resp as { content?: Array<{ text?: string }> }\n const text = r.content?.[0]?.text\n if (typeof text !== 'string') return {}\n try { return JSON.parse(text) as ToolResult } catch { return {} }\n}\n\nexport async function runRegistrationSequence(\n config: RegistrationConfig\n): Promise<RegistrationSequenceResult> {\n const order: string[] = []\n const transport = new StreamableHTTPClientTransport(new URL(config.daemonUrl))\n const client = new Client({ name: 'cross-agent-teams-proxy', version: '0.1.0' })\n\n if (config.notificationHandler) {\n client.fallbackNotificationHandler = async (n) => {\n if (n.method === 'notifications/channel_wake') {\n config.notificationHandler!(n.params)\n }\n }\n }\n\n await client.connect(transport)\n\n // 1. register_agent as proxy — identity keyed on pid, stable across reconnects\n // so the (team, name) ON CONFLICT upsert reuses the same row instead of spamming new rows\n const registerResp = await client.callTool({\n name: 'register_agent',\n arguments: {\n client: 'custom',\n client_name: 'cross-agent-teams-channel',\n model: 'proxy',\n role: '__channel_proxy__',\n name: `channel-proxy-${process.pid}`,\n team: 'default',\n claude_ui_pid: process.ppid,\n delivery: {\n kind: 'claude-channel',\n channel_session_id: config.channel_session_id,\n },\n }\n })\n order.push('register_agent')\n const regResult = await parseToolResult(registerResp)\n if (!('agent_id' in regResult)) {\n throw new Error(`register_agent failed: ${JSON.stringify(regResult)}`)\n }\n\n // 2. subscribe_channel_wake — proxy's csid is fresh per startup\n const subResp = await client.callTool({\n name: 'subscribe_channel_wake',\n arguments: { channel_session_id: config.channel_session_id }\n })\n order.push('subscribe_channel_wake')\n const subResult = await parseToolResult(subResp)\n if (!('ok' in subResult) || subResult.ok !== true) {\n throw new Error(`subscribe_channel_wake failed: ${JSON.stringify(subResult)}`)\n }\n\n return {\n order,\n lastSubscribeResult: subResult,\n client,\n transport,\n close: async () => {\n try { await client.close() } catch { /* best-effort */ }\n try { await transport.close() } catch { /* best-effort */ }\n }\n }\n}\n\nexport function runReconnectingProxy(config: ReconnectingProxyConfig): ReconnectingProxyController {\n let stopped = false\n let currentSeq: RegistrationSequenceResult | null = null\n\n async function waitForDisconnect(seq: RegistrationSequenceResult): Promise<void> {\n const interval = config.healthCheckIntervalMs ?? 200\n let disconnected = false\n const closeHandler = () => { disconnected = true }\n const prevOnClose = seq.transport.onclose\n seq.transport.onclose = () => { prevOnClose?.(); closeHandler() }\n while (!disconnected && !stopped) {\n await new Promise(r => setTimeout(r, interval))\n if (disconnected || stopped) break\n try {\n await seq.client.callTool({ name: 'echo', arguments: { msg: 'hb' } })\n } catch {\n disconnected = true\n break\n }\n }\n }\n\n async function loop(): Promise<void> {\n while (!stopped) {\n try {\n const seq = await runRegistrationSequence(config)\n currentSeq = seq\n if (config.onSequenceComplete) config.onSequenceComplete([...seq.order])\n\n await waitForDisconnect(seq)\n if (config.onDisconnect) config.onDisconnect()\n try { await seq.close() } catch { /* best-effort */ }\n currentSeq = null\n } catch {\n // register/subscribe failed — wait and retry.\n }\n if (stopped) break\n const wait = config.backoffInitialMs ?? 500\n await new Promise(r => setTimeout(r, wait))\n }\n }\n\n void loop()\n\n return {\n stop: async () => {\n stopped = true\n if (currentSeq) {\n try { await currentSeq.close() } catch { /* best-effort */ }\n }\n }\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cross-agent-teams-mcp",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "MCP daemon for cross-agent collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,130 +0,0 @@
1
- import { spawn, type SpawnOptions } from 'node:child_process'
2
- import { fileURLToPath } from 'node:url'
3
- import { mkdirSync, openSync } from 'node:fs'
4
- import { homedir } from 'node:os'
5
- import { dirname, join } from 'node:path'
6
-
7
- export interface EnsureDaemonHealthyOptions {
8
- daemonUrl: string
9
- log?: NodeJS.WritableStream
10
- // Test-only injection points; production callers leave these undefined.
11
- fetchImpl?: typeof fetch
12
- spawnImpl?: typeof spawn
13
- daemonEntryOverride?: string
14
- probeTimeoutMs?: number
15
- pollAttempts?: number
16
- pollIntervalMs?: number
17
- logFilePath?: string
18
- }
19
-
20
- const DEFAULT_LOG_PATH = join(homedir(), '.cross-agent-teams-mcp', 'daemon.log')
21
-
22
- function isLoopbackHost(host: string): boolean {
23
- return host === '127.0.0.1' || host === 'localhost'
24
- }
25
-
26
- function originOf(url: URL): string {
27
- return `${url.protocol}//${url.host}`
28
- }
29
-
30
- async function probeHealth(
31
- origin: string,
32
- timeoutMs: number,
33
- fetchImpl: typeof fetch
34
- ): Promise<boolean> {
35
- const ac = new AbortController()
36
- const timer = setTimeout(() => ac.abort(), timeoutMs)
37
- try {
38
- const resp = await fetchImpl(`${origin}/health`, { signal: ac.signal })
39
- return resp.status >= 200 && resp.status < 300
40
- } catch {
41
- return false
42
- } finally {
43
- clearTimeout(timer)
44
- }
45
- }
46
-
47
- function resolveDaemonEntry(): string {
48
- // Test escape hatch: when set, tests can inject a fake daemon entry without
49
- // having to mock child_process.spawn inside a subprocess.
50
- const override = process.env.CROSS_AGENT_TEAMS_CHANNEL_DAEMON_ENTRY
51
- if (override && override.length > 0) return override
52
- // After tsup bundles, this module is inlined into dist/channel-cli.js;
53
- // the daemon entry sits next to it as dist/cli.js. Resolving relative to
54
- // import.meta.url (which equals the channel-cli.js URL post-build) gives
55
- // the right neighbor file.
56
- return fileURLToPath(new URL('./cli.js', import.meta.url))
57
- }
58
-
59
- function ensureLogDir(logPath: string): void {
60
- try {
61
- mkdirSync(dirname(logPath), { recursive: true })
62
- } catch {
63
- // best-effort — spawn will fail clearly if path still unusable
64
- }
65
- }
66
-
67
- export async function ensureDaemonHealthy(opts: EnsureDaemonHealthyOptions): Promise<void> {
68
- const fetchImpl = opts.fetchImpl ?? fetch
69
- const spawnImpl = opts.spawnImpl ?? spawn
70
- const probeTimeoutMs = opts.probeTimeoutMs ?? 2000
71
- const pollAttempts = opts.pollAttempts ?? 20
72
- const pollIntervalMs = opts.pollIntervalMs ?? 250
73
- const logFilePath = opts.logFilePath ?? DEFAULT_LOG_PATH
74
-
75
- let parsed: URL
76
- try {
77
- parsed = new URL(opts.daemonUrl)
78
- } catch {
79
- throw new Error(`invalid daemon URL: ${opts.daemonUrl}`)
80
- }
81
-
82
- const origin = originOf(parsed)
83
- const host = parsed.hostname
84
- const port = parsed.port ? Number(parsed.port) : null
85
-
86
- // Step 1: probe.
87
- if (await probeHealth(origin, probeTimeoutMs, fetchImpl)) return
88
-
89
- // Step 2: non-loopback → no spawn, error out.
90
- if (!isLoopbackHost(host)) {
91
- throw new Error(
92
- `daemon at ${opts.daemonUrl} not reachable; auto-spawn disabled for non-loopback URLs`
93
- )
94
- }
95
-
96
- // Step 3: spawn detached daemon.
97
- const entry = opts.daemonEntryOverride ?? resolveDaemonEntry()
98
- const args = ['daemon']
99
- if (port !== null && Number.isFinite(port)) {
100
- args.push('--port', String(port))
101
- }
102
-
103
- ensureLogDir(logFilePath)
104
- let logFd: number | undefined
105
- try {
106
- logFd = openSync(logFilePath, 'a')
107
- } catch {
108
- logFd = undefined
109
- }
110
-
111
- const spawnOptions: SpawnOptions = {
112
- detached: true,
113
- stdio: logFd !== undefined
114
- ? ['ignore', logFd, logFd]
115
- : 'ignore'
116
- }
117
- const child = spawnImpl(process.execPath, [entry, ...args], spawnOptions)
118
- try { child.unref() } catch { /* best-effort */ }
119
-
120
- // Step 4: poll /health.
121
- for (let i = 0; i < pollAttempts; i++) {
122
- await new Promise(r => setTimeout(r, pollIntervalMs))
123
- if (await probeHealth(origin, probeTimeoutMs, fetchImpl)) return
124
- }
125
-
126
- throw new Error(
127
- `daemon failed to become healthy at ${opts.daemonUrl} within bootstrap deadline; ` +
128
- `see log at ${logFilePath}`
129
- )
130
- }
@@ -1,184 +0,0 @@
1
- import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
3
-
4
- export interface RegistrationConfig {
5
- daemonUrl: string
6
- channel_session_id: string
7
- backoffInitialMs?: number
8
- backoffMaxMs?: number
9
- notificationHandler?: (payload: unknown) => void
10
- }
11
-
12
- export interface ReconnectingProxyConfig extends RegistrationConfig {
13
- onSequenceComplete?: (order: string[]) => void
14
- onDisconnect?: () => void
15
- healthCheckIntervalMs?: number
16
- }
17
-
18
- export interface ReconnectingProxyController {
19
- stop(): Promise<void>
20
- getClient(): Client | null
21
- }
22
-
23
- export interface RegistrationSequenceResult {
24
- order: string[]
25
- lastSubscribeResult: unknown
26
- client: Client
27
- transport: StreamableHTTPClientTransport
28
- close: () => Promise<void>
29
- }
30
-
31
- type ToolResult = Record<string, unknown>
32
-
33
- async function parseToolResult(resp: unknown): Promise<ToolResult> {
34
- const r = resp as { content?: Array<{ text?: string }> }
35
- const text = r.content?.[0]?.text
36
- if (typeof text !== 'string') return {}
37
- try { return JSON.parse(text) as ToolResult } catch { return {} }
38
- }
39
-
40
- export async function runRegistrationSequence(
41
- config: RegistrationConfig
42
- ): Promise<RegistrationSequenceResult> {
43
- const order: string[] = []
44
- const transport = new StreamableHTTPClientTransport(new URL(config.daemonUrl))
45
- const client = new Client({ name: 'cross-agent-teams-channel', version: '0.1.0' })
46
-
47
- if (config.notificationHandler) {
48
- client.fallbackNotificationHandler = async (n) => {
49
- if (n.method === 'notifications/channel_wake') {
50
- config.notificationHandler!(n.params)
51
- }
52
- }
53
- }
54
-
55
- await client.connect(transport)
56
-
57
- // 1. register_agent as proxy — identity keyed on pid, stable across reconnects
58
- // so the (team, name) ON CONFLICT upsert reuses the same row instead of spamming new rows
59
- const registerResp = await client.callTool({
60
- name: 'register_agent',
61
- arguments: {
62
- client: 'custom',
63
- client_name: 'cross-agent-teams-channel',
64
- model: 'proxy',
65
- role: '__channel_proxy__',
66
- name: `channel-proxy-${process.pid}`,
67
- team: 'default',
68
- claude_ui_pid: process.ppid,
69
- delivery: {
70
- kind: 'claude-channel',
71
- channel_session_id: config.channel_session_id,
72
- },
73
- }
74
- })
75
- order.push('register_agent')
76
- const regResult = await parseToolResult(registerResp)
77
- if (!('agent_id' in regResult)) {
78
- throw new Error(`register_agent failed: ${JSON.stringify(regResult)}`)
79
- }
80
-
81
- // 2. subscribe_channel_wake — proxy's csid is fresh per startup
82
- const subResp = await client.callTool({
83
- name: 'subscribe_channel_wake',
84
- arguments: { channel_session_id: config.channel_session_id }
85
- })
86
- order.push('subscribe_channel_wake')
87
- const subResult = await parseToolResult(subResp)
88
- if (!('ok' in subResult) || subResult.ok !== true) {
89
- throw new Error(`subscribe_channel_wake failed: ${JSON.stringify(subResult)}`)
90
- }
91
-
92
- return {
93
- order,
94
- lastSubscribeResult: subResult,
95
- client,
96
- transport,
97
- close: async () => {
98
- try { await client.close() } catch { /* best-effort */ }
99
- try { await transport.close() } catch { /* best-effort */ }
100
- }
101
- }
102
- }
103
-
104
- export async function runReconnectingProxy(config: ReconnectingProxyConfig): Promise<ReconnectingProxyController> {
105
- let stopped = false
106
- let currentSeq: RegistrationSequenceResult | null = null
107
-
108
- async function waitForDisconnect(seq: RegistrationSequenceResult): Promise<void> {
109
- const interval = config.healthCheckIntervalMs ?? 200
110
- let disconnected = false
111
- const closeHandler = () => { disconnected = true }
112
- const prevOnClose = seq.transport.onclose
113
- seq.transport.onclose = () => { prevOnClose?.(); closeHandler() }
114
- while (!disconnected && !stopped) {
115
- await new Promise(r => setTimeout(r, interval))
116
- if (disconnected || stopped) break
117
- try {
118
- await seq.client.callTool({ name: 'echo', arguments: { msg: 'hb' } })
119
- } catch {
120
- disconnected = true
121
- break
122
- }
123
- }
124
- }
125
-
126
- // First registration is awaited synchronously so callers can rely on
127
- // getClient() returning a live Client immediately after this resolves.
128
- // This eliminates a cold-start race where forwarded tools/list / tools/call
129
- // requests from a host (e.g. Claude Code) could arrive before the
130
- // daemon-facing client finished registering.
131
- const initialSeq = await runRegistrationSequence(config)
132
- currentSeq = initialSeq
133
- if (config.onSequenceComplete) config.onSequenceComplete([...initialSeq.order])
134
-
135
- async function backgroundLoop(): Promise<void> {
136
- while (!stopped) {
137
- const seq = currentSeq
138
- if (!seq) {
139
- // currentSeq cleared by a prior failed reconnect; wait and try again.
140
- const wait = config.backoffInitialMs ?? 500
141
- await new Promise(r => setTimeout(r, wait))
142
- if (stopped) break
143
- try {
144
- const next = await runRegistrationSequence(config)
145
- currentSeq = next
146
- if (config.onSequenceComplete) config.onSequenceComplete([...next.order])
147
- } catch {
148
- // try again next iteration
149
- }
150
- continue
151
- }
152
-
153
- await waitForDisconnect(seq)
154
- if (config.onDisconnect) config.onDisconnect()
155
- try { await seq.close() } catch { /* best-effort */ }
156
- currentSeq = null
157
- if (stopped) break
158
-
159
- const wait = config.backoffInitialMs ?? 500
160
- await new Promise(r => setTimeout(r, wait))
161
- if (stopped) break
162
-
163
- try {
164
- const next = await runRegistrationSequence(config)
165
- currentSeq = next
166
- if (config.onSequenceComplete) config.onSequenceComplete([...next.order])
167
- } catch {
168
- // currentSeq stays null; loop will retry after backoff.
169
- }
170
- }
171
- }
172
-
173
- void backgroundLoop()
174
-
175
- return {
176
- stop: async () => {
177
- stopped = true
178
- if (currentSeq) {
179
- try { await currentSeq.close() } catch { /* best-effort */ }
180
- }
181
- },
182
- getClient: () => currentSeq?.client ?? null
183
- }
184
- }
@@ -1,70 +0,0 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
- import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3
- import {
4
- CallToolRequestSchema,
5
- ErrorCode,
6
- ListToolsRequestSchema,
7
- McpError
8
- } from '@modelcontextprotocol/sdk/types.js'
9
-
10
- const PROXY_INTERNAL_TOOL = 'subscribe_channel_wake'
11
-
12
- export function createProxyServer(): McpServer {
13
- return new McpServer(
14
- { name: 'cross-agent-teams-channel', version: '0.1.0' },
15
- { capabilities: { tools: {}, experimental: { 'claude/channel': {} } } }
16
- )
17
- }
18
-
19
- export interface ChannelWakeParams {
20
- content: string
21
- meta: Record<string, string>
22
- }
23
-
24
- export function relayChannelWake(server: McpServer, params: ChannelWakeParams): void {
25
- try {
26
- const notif = {
27
- method: 'notifications/claude/channel',
28
- params: params as unknown as Record<string, unknown>
29
- }
30
- const p = (server.server.notification as (n: typeof notif) => Promise<void>)(notif)
31
- if (p && typeof p.catch === 'function') {
32
- p.catch(() => { /* host closed — drop silently */ })
33
- }
34
- } catch {
35
- // host transport closed or not yet connected — drop silently
36
- }
37
- }
38
-
39
- export type GetDaemonClient = () => Client | null
40
-
41
- export function installForwardingHandlers(
42
- server: McpServer,
43
- getDaemonClient: GetDaemonClient
44
- ): void {
45
- const inner = server.server
46
-
47
- inner.setRequestHandler(ListToolsRequestSchema, async (request) => {
48
- const client = getDaemonClient()
49
- if (!client) {
50
- throw new McpError(ErrorCode.InternalError, 'daemon not connected')
51
- }
52
- const result = await client.listTools(request.params)
53
- return {
54
- ...result,
55
- tools: result.tools.filter((t) => t.name !== PROXY_INTERNAL_TOOL)
56
- }
57
- })
58
-
59
- inner.setRequestHandler(CallToolRequestSchema, async (request) => {
60
- const name = request.params?.name
61
- if (name === PROXY_INTERNAL_TOOL) {
62
- throw new McpError(ErrorCode.MethodNotFound, `tool not available: ${PROXY_INTERNAL_TOOL}`)
63
- }
64
- const client = getDaemonClient()
65
- if (!client) {
66
- throw new McpError(ErrorCode.InternalError, 'daemon not connected')
67
- }
68
- return await client.callTool(request.params)
69
- })
70
- }
@@ -1,162 +0,0 @@
1
- #!/usr/bin/env node
2
- import { randomUUID } from 'node:crypto'
3
- import { realpathSync } from 'node:fs'
4
- import { fileURLToPath } from 'node:url'
5
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
6
- import { createProxyServer, installForwardingHandlers, relayChannelWake } from './channel/proxy.js'
7
- import { runReconnectingProxy } from './channel/daemon-client.js'
8
- import { ensureDaemonHealthy } from './channel/auto-daemon.js'
9
-
10
- const DEFAULT_DAEMON_URL = 'http://127.0.0.1:9100/mcp'
11
-
12
- interface CliArgs {
13
- daemonUrl: string
14
- }
15
-
16
- export class CliArgError extends Error {
17
- constructor(message: string) {
18
- super(message)
19
- this.name = 'CliArgError'
20
- }
21
- }
22
-
23
- export function buildStartupHint(csid: string): { content: string; meta: { source: string; kind: string } } {
24
- const content = [
25
- `cross-agent-teams-mcp: your channel_session_id is ${csid}.`,
26
- `You are connected to the cross-agent-teams (xats) collaboration server but NOT yet registered as an agent. Before doing other work in this session, ask the user with this exact English prompt (verbatim):`,
27
- `"To collaborate via xats, please tell me: register to xats, name: <preferred-name>, team: <preferred-team>"`,
28
- `When the user replies, call list_agents first to verify the chosen name is not already in use within that team; if it is taken, ask the user for a different name.`,
29
- `Once you have a unique name, call register_claude_self({name, team, ui_pid: $PPID}) to complete registration. ui_pid is the Claude Code CLI parent process pid (run \`echo $PPID\` from a Bash tool to obtain it). Do NOT pass channel_session_id; the daemon auto-binds via ui_pid.`,
30
- `The unified equivalent register_agent({client: "claude-code", name, team, model, ui_pid: $PPID}) also works without channel_session_id.`,
31
- `bind_channel({channel_session_id: "${csid}"}) is the low-level rebind tool for an already-registered Claude host that needs to switch to a fresh csid; it is NOT the primary registration path.`,
32
- `Do not use curl or another external HTTP client for Claude registration here — that would create a different MCP session, and follow-up tools in Claude Code could still see unknown_agent.`
33
- ].join(' ')
34
- return {
35
- content,
36
- meta: { source: 'cross_agent_teams_mcp', kind: 'startup_bind_hint' }
37
- }
38
- }
39
-
40
- export function parseCliArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): CliArgs {
41
- let daemonUrl: string | undefined
42
-
43
- for (let i = 0; i < argv.length; i++) {
44
- const flag = argv[i]
45
- const next = argv[i + 1]
46
- switch (flag) {
47
- case '--daemon-url':
48
- daemonUrl = next; i++; break
49
- default:
50
- // Ignore unknown flags for forward-compat (including legacy
51
- // --agent-team / --agent-name, which are no longer honored).
52
- break
53
- }
54
- }
55
-
56
- if (!daemonUrl || daemonUrl.length === 0) {
57
- daemonUrl = env.CROSS_AGENT_TEAMS_MCP_DAEMON_URL
58
- }
59
-
60
- if (!daemonUrl || daemonUrl.length === 0) {
61
- daemonUrl = DEFAULT_DAEMON_URL
62
- }
63
- return { daemonUrl }
64
- }
65
-
66
- export async function main(
67
- argv: readonly string[] = process.argv.slice(2),
68
- env: NodeJS.ProcessEnv = process.env
69
- ): Promise<void> {
70
- let args: CliArgs
71
- try {
72
- args = parseCliArgs(argv, env)
73
- } catch (err) {
74
- const msg = err instanceof Error ? err.message : String(err)
75
- process.stderr.write(`cross-agent-teams-channel: ${msg}\n`)
76
- process.exit(2)
77
- }
78
-
79
- // Fresh csid per startup — no persistence. Multi-instance safe.
80
- const csid = randomUUID()
81
-
82
- try {
83
- await ensureDaemonHealthy({ daemonUrl: args.daemonUrl, log: process.stderr })
84
- } catch (err) {
85
- const msg = err instanceof Error ? err.message : String(err)
86
- process.stderr.write(`cross-agent-teams-channel: ${msg}\n`)
87
- process.exit(2)
88
- }
89
-
90
- const hostServer = createProxyServer()
91
- const stdioTransport = new StdioServerTransport()
92
-
93
- // Await the first registration before connecting stdio so the proxy's
94
- // forwarding handlers see a live daemon-facing client on the very first
95
- // host request. Without this, a host that issues tools/list immediately
96
- // after initialize can race the background register/subscribe and observe
97
- // an empty / errored tool list which Claude Code caches for the session.
98
- let controller: Awaited<ReturnType<typeof runReconnectingProxy>>
99
- try {
100
- controller = await runReconnectingProxy({
101
- daemonUrl: args.daemonUrl,
102
- channel_session_id: csid,
103
- notificationHandler: (params) => {
104
- relayChannelWake(hostServer, params as { content: string; meta: Record<string, string> })
105
- }
106
- // No onSequenceComplete: the startup hint is emitted from
107
- // hostServer.server.oninitialized below (fires after the host's
108
- // MCP initialize handshake completes). Reconnects do not re-emit
109
- // because csid is stable across reconnects and the host has long
110
- // since seen it.
111
- })
112
- } catch (err) {
113
- const msg = err instanceof Error ? err.message : String(err)
114
- process.stderr.write(`cross-agent-teams-channel: initial daemon registration failed: ${msg}\n`)
115
- process.exit(2)
116
- }
117
-
118
- installForwardingHandlers(hostServer, () => controller.getClient())
119
-
120
- // Emit the startup hint after the host has completed its MCP initialize
121
- // handshake (i.e., sent `notifications/initialized`). Sending earlier
122
- // risks Claude Code dropping the notification because the experimental
123
- // `claude/channel` capability hasn't been negotiated yet on its side.
124
- hostServer.server.oninitialized = () => {
125
- relayChannelWake(hostServer, buildStartupHint(csid))
126
- }
127
-
128
- let stopped = false
129
- const shutdown = async (): Promise<void> => {
130
- if (stopped) return
131
- stopped = true
132
- try { await controller.stop() } catch { /* best-effort */ }
133
- try { await hostServer.close() } catch { /* best-effort */ }
134
- process.exit(0)
135
- }
136
-
137
- stdioTransport.onclose = () => { void shutdown() }
138
-
139
- await hostServer.connect(stdioTransport)
140
- // The initial startup hint is emitted from the oninitialized hook above,
141
- // which fires after the host completes its MCP initialize handshake.
142
-
143
- process.on('SIGTERM', () => { void shutdown() })
144
- process.on('SIGINT', () => { void shutdown() })
145
- }
146
-
147
- function isInvokedAsEntry(): boolean {
148
- const argv1 = process.argv[1]
149
- if (!argv1) return false
150
- try {
151
- return realpathSync(argv1) === fileURLToPath(import.meta.url)
152
- } catch {
153
- return false
154
- }
155
- }
156
-
157
- if (isInvokedAsEntry()) {
158
- main().catch((e) => {
159
- process.stderr.write(`cross-agent-teams-channel: ${e?.message ?? e}\n`)
160
- process.exit(1)
161
- })
162
- }