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 +39 -43
- package/README.zh-CN.md +39 -43
- package/dist/channel-cli.js +44 -213
- package/dist/channel-cli.js.map +1 -1
- package/package.json +1 -1
- package/src/channel/auto-daemon.ts +0 -130
- package/src/channel/daemon-client.ts +0 -184
- package/src/channel/proxy.ts +0 -70
- package/src/channel-cli.ts +0 -162
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
29
|
+
```json
|
|
19
30
|
{
|
|
20
31
|
"mcpServers": {
|
|
21
|
-
"cross-agent-teams": {
|
|
32
|
+
"cross-agent-teams-channel": {
|
|
22
33
|
"command": "npx",
|
|
23
|
-
"args": [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
+
### 3. Start Claude Code with the channel loader
|
|
34
51
|
|
|
35
52
|
```bash
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
./start-server.sh
|
|
44
|
-
```
|
|
58
|
+
### 4. Register from inside the agent
|
|
45
59
|
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
./stop-server.sh
|
|
50
|
-
```
|
|
62
|
+
### Running from source
|
|
51
63
|
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
+
### 2. 让 Claude Code 通过 channel proxy 连接
|
|
17
26
|
|
|
18
|
-
|
|
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": [
|
|
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
|
-
|
|
46
|
+
如果你在启动 daemon 时带了 `--token <t>`, 走环境变量 `CROSS_AGENT_TEAMS_MCP_DAEMON_URL` 配合 daemon 侧的 header 配置; proxy 自身只读取 daemon URL.
|
|
30
47
|
|
|
31
|
-
|
|
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
|
-
|
|
50
|
+
### 3. 用 channel loader 启动 Claude Code
|
|
34
51
|
|
|
35
52
|
```bash
|
|
36
|
-
|
|
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
|
-
|
|
43
|
-
./start-server.sh
|
|
44
|
-
```
|
|
58
|
+
### 4. 在 agent 里完成注册
|
|
45
59
|
|
|
46
|
-
|
|
60
|
+
Claude Code 经由 proxy 连上之后, 在 agent 会话里调用 `register_claude_self` / `register_codex_self` / `register_agent` 完成注册, 详见后续章节.
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
./stop-server.sh
|
|
50
|
-
```
|
|
62
|
+
### 从源码运行
|
|
51
63
|
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/channel-cli.js
CHANGED
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/
|
|
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
|
-
//
|
|
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: {
|
|
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
|
-
//
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
currentSeq = initialSeq;
|
|
163
|
-
if (config.onSequenceComplete) config.onSequenceComplete([...initialSeq.order]);
|
|
164
|
-
async function backgroundLoop() {
|
|
127
|
+
async function loop() {
|
|
165
128
|
while (!stopped) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
currentSeq = next;
|
|
174
|
-
if (config.onSequenceComplete) config.onSequenceComplete([...next.order]);
|
|
136
|
+
await seq.close();
|
|
175
137
|
} catch {
|
|
176
138
|
}
|
|
177
|
-
|
|
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
|
|
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/
|
|
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
|
-
`
|
|
313
|
-
`
|
|
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
|
-
|
|
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-
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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,
|
package/dist/channel-cli.js.map
CHANGED
|
@@ -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,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
|
-
}
|
package/src/channel/proxy.ts
DELETED
|
@@ -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
|
-
}
|
package/src/channel-cli.ts
DELETED
|
@@ -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
|
-
}
|