cross-agent-teams-mcp 0.2.5 → 0.3.0
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 +29 -47
- package/README.zh-CN.md +29 -47
- package/package.json +2 -3
- package/dist/channel-cli.d.ts +0 -18
- package/dist/channel-cli.js +0 -439
- package/dist/channel-cli.js.map +0 -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 -168
package/README.md
CHANGED
|
@@ -6,76 +6,58 @@ 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 a single long-running HTTP daemon. You start it once on your machine, then point Claude Code / Codex / opencode at it as an MCP server. There is no stdio entry point and no "auto bootstrap" — start the daemon explicitly first, agents connect to it second.
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
The recommended Claude Code MCP config collapses to a single stdio entry — no manual daemon startup needed:
|
|
17
|
-
|
|
18
|
-
```jsonc
|
|
19
|
-
{
|
|
20
|
-
"mcpServers": {
|
|
21
|
-
"cross-agent-teams": {
|
|
22
|
-
"command": "npx",
|
|
23
|
-
"args": ["-y", "-p", "cross-agent-teams-mcp", "cross-agent-teams-channel"]
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
13
|
+
```bash
|
|
14
|
+
npx -y cross-agent-teams-mcp@latest daemon --port 9100
|
|
27
15
|
```
|
|
28
16
|
|
|
29
|
-
|
|
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`.
|
|
30
18
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
If you prefer explicit lifecycle control or are developing locally, you can still build and start the daemon manually:
|
|
19
|
+
You can verify the service with:
|
|
34
20
|
|
|
35
21
|
```bash
|
|
36
|
-
|
|
37
|
-
node dist/cli.js daemon --port 9100
|
|
22
|
+
curl http://127.0.0.1:9100/health
|
|
38
23
|
```
|
|
39
24
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
./start-server.sh
|
|
44
|
-
```
|
|
25
|
+
### 2. Configure your agent's MCP client
|
|
45
26
|
|
|
46
|
-
|
|
27
|
+
Point your agent at the running daemon over Streamable HTTP. For Claude Code (`~/.claude.json` or `.mcp.json`):
|
|
47
28
|
|
|
48
|
-
```
|
|
49
|
-
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"cross-agent-teams": {
|
|
33
|
+
"type": "http",
|
|
34
|
+
"url": "http://127.0.0.1:9100/mcp"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
50
38
|
```
|
|
51
39
|
|
|
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.
|
|
40
|
+
For Codex CLI, see [docs/configs/codex-cli.md](docs/configs/codex-cli.md). For opencode see the "Using opencode with xats (tmux)" section below.
|
|
57
41
|
|
|
58
|
-
|
|
42
|
+
If you started the daemon with `--token <t>`, add `"headers": { "Authorization": "Bearer <t>" }` to the client configuration.
|
|
59
43
|
|
|
60
|
-
###
|
|
44
|
+
### 3. Register from inside the agent
|
|
61
45
|
|
|
62
|
-
|
|
46
|
+
Once the agent's MCP client is connected, register from inside the agent session — see `register_claude_self`, `register_codex_self`, and `register_agent` below.
|
|
63
47
|
|
|
64
|
-
|
|
48
|
+
### Running from source
|
|
65
49
|
|
|
66
|
-
|
|
50
|
+
If you cloned this repo and want to run the daemon from source:
|
|
67
51
|
|
|
68
52
|
```bash
|
|
53
|
+
pnpm install
|
|
54
|
+
pnpm build
|
|
55
|
+
node dist/cli.js daemon --port 9100
|
|
56
|
+
# or, without a build step:
|
|
69
57
|
npx tsx src/cli.ts daemon --port 9100
|
|
70
58
|
```
|
|
71
59
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
You can verify the service with:
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
curl http://127.0.0.1:9100/health
|
|
78
|
-
```
|
|
60
|
+
`./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
61
|
|
|
80
62
|
## Common Flags
|
|
81
63
|
|
package/README.zh-CN.md
CHANGED
|
@@ -6,76 +6,58 @@
|
|
|
6
6
|
|
|
7
7
|
## 快速开始
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
这个包只发一个常驻的 HTTP daemon. 你在本机起一次, 然后让 Claude Code / Codex / opencode 当作 MCP server 接进来. 没有 stdio 入口, 也没有"自动拉起 daemon", 顺序就是: 先显式起 daemon, 再让 agent 连过来.
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
推荐的 Claude Code MCP config 折叠成单一 stdio entry, 不再需要手动起 daemon:
|
|
17
|
-
|
|
18
|
-
```jsonc
|
|
19
|
-
{
|
|
20
|
-
"mcpServers": {
|
|
21
|
-
"cross-agent-teams": {
|
|
22
|
-
"command": "npx",
|
|
23
|
-
"args": ["-y", "-p", "cross-agent-teams-mcp", "cross-agent-teams-channel"]
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
13
|
+
```bash
|
|
14
|
+
npx -y cross-agent-teams-mcp@latest daemon --port 9100
|
|
27
15
|
```
|
|
28
16
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
如果 URL 不是 loopback (比如 `http://10.0.0.5:9100/mcp`), 自动 spawn 会被禁用 — channel 只探测一次, daemon 不可达就报错退出。
|
|
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`.
|
|
32
18
|
|
|
33
|
-
|
|
19
|
+
启动后可以用下面命令确认服务正常:
|
|
34
20
|
|
|
35
21
|
```bash
|
|
36
|
-
|
|
37
|
-
node dist/cli.js daemon --port 9100
|
|
22
|
+
curl http://127.0.0.1:9100/health
|
|
38
23
|
```
|
|
39
24
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
./start-server.sh
|
|
44
|
-
```
|
|
25
|
+
### 2. 在 agent 里配置 MCP client
|
|
45
26
|
|
|
46
|
-
|
|
27
|
+
让 agent 通过 Streamable HTTP 连到正在运行的 daemon. Claude Code (`~/.claude.json` 或 `.mcp.json`) 配置示例:
|
|
47
28
|
|
|
48
|
-
```
|
|
49
|
-
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"cross-agent-teams": {
|
|
33
|
+
"type": "http",
|
|
34
|
+
"url": "http://127.0.0.1:9100/mcp"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
50
38
|
```
|
|
51
39
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
## 分发说明
|
|
40
|
+
Codex CLI 的配置见 [docs/configs/codex-cli.md](docs/configs/codex-cli.md). opencode 见下面的 "Using opencode with xats (tmux)" 章节.
|
|
55
41
|
|
|
56
|
-
|
|
42
|
+
如果你启动 daemon 时带了 `--token <t>`, 在 client 配置中加上 `"headers": { "Authorization": "Bearer <t>" }` 即可.
|
|
57
43
|
|
|
58
|
-
|
|
44
|
+
### 3. 在 agent 里完成注册
|
|
59
45
|
|
|
60
|
-
|
|
46
|
+
agent 连上 MCP 后, 在 agent 会话里调用 `register_claude_self` / `register_codex_self` / `register_agent` 完成注册, 详见后续章节.
|
|
61
47
|
|
|
62
|
-
|
|
48
|
+
### 从源码运行
|
|
63
49
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
如果你想直接跑源码, 可以用:
|
|
50
|
+
如果你 clone 了仓库想直接跑源码:
|
|
67
51
|
|
|
68
52
|
```bash
|
|
53
|
+
pnpm install
|
|
54
|
+
pnpm build
|
|
55
|
+
node dist/cli.js daemon --port 9100
|
|
56
|
+
# 或者跳过 build:
|
|
69
57
|
npx tsx src/cli.ts daemon --port 9100
|
|
70
58
|
```
|
|
71
59
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
启动后可以用下面的命令确认服务正常:
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
curl http://127.0.0.1:9100/health
|
|
78
|
-
```
|
|
60
|
+
`./start-server.sh` / `./stop-server.sh` 是本地开发用的便捷脚本, 会顺手把 Codex app-server 一起拉起来; 通过 `npx` 使用本包时不需要它们.
|
|
79
61
|
|
|
80
62
|
## 常用参数
|
|
81
63
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cross-agent-teams-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP daemon for cross-agent collaboration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"cross-agent-teams-mcp": "./dist/cli.js"
|
|
8
|
-
"cross-agent-teams-channel": "./dist/channel-cli.js"
|
|
7
|
+
"cross-agent-teams-mcp": "./dist/cli.js"
|
|
9
8
|
},
|
|
10
9
|
"files": [
|
|
11
10
|
"dist",
|
package/dist/channel-cli.d.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
interface CliArgs {
|
|
3
|
-
daemonUrl: string;
|
|
4
|
-
}
|
|
5
|
-
declare class CliArgError extends Error {
|
|
6
|
-
constructor(message: string);
|
|
7
|
-
}
|
|
8
|
-
declare function buildStartupHint(csid: string): {
|
|
9
|
-
content: string;
|
|
10
|
-
meta: {
|
|
11
|
-
source: string;
|
|
12
|
-
kind: string;
|
|
13
|
-
};
|
|
14
|
-
};
|
|
15
|
-
declare function parseCliArgs(argv: readonly string[], env?: NodeJS.ProcessEnv): CliArgs;
|
|
16
|
-
declare function main(argv?: readonly string[], env?: NodeJS.ProcessEnv): Promise<void>;
|
|
17
|
-
|
|
18
|
-
export { CliArgError, buildStartupHint, main, parseCliArgs };
|
package/dist/channel-cli.js
DELETED
|
@@ -1,439 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// src/channel-cli.ts
|
|
4
|
-
import { randomUUID } from "crypto";
|
|
5
|
-
import { realpathSync } from "fs";
|
|
6
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
7
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
-
|
|
9
|
-
// src/channel/proxy.ts
|
|
10
|
-
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
|
-
function createProxyServer() {
|
|
19
|
-
return new McpServer(
|
|
20
|
-
{ name: "cross-agent-teams-channel", version: "0.1.0" },
|
|
21
|
-
{ capabilities: { tools: {}, experimental: { "claude/channel": {} } } }
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
function relayChannelWake(server, params) {
|
|
25
|
-
try {
|
|
26
|
-
const notif = {
|
|
27
|
-
method: "notifications/claude/channel",
|
|
28
|
-
params
|
|
29
|
-
};
|
|
30
|
-
const p = server.server.notification(notif);
|
|
31
|
-
if (p && typeof p.catch === "function") {
|
|
32
|
-
p.catch(() => {
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
} catch {
|
|
36
|
-
}
|
|
37
|
-
}
|
|
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
|
-
|
|
64
|
-
// src/channel/daemon-client.ts
|
|
65
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
66
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
67
|
-
async function parseToolResult(resp) {
|
|
68
|
-
const r = resp;
|
|
69
|
-
const text = r.content?.[0]?.text;
|
|
70
|
-
if (typeof text !== "string") return {};
|
|
71
|
-
try {
|
|
72
|
-
return JSON.parse(text);
|
|
73
|
-
} catch {
|
|
74
|
-
return {};
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
async function runRegistrationSequence(config) {
|
|
78
|
-
const order = [];
|
|
79
|
-
const transport = new StreamableHTTPClientTransport(new URL(config.daemonUrl));
|
|
80
|
-
const client = new Client({ name: "cross-agent-teams-channel", version: "0.1.0" });
|
|
81
|
-
if (config.notificationHandler) {
|
|
82
|
-
client.fallbackNotificationHandler = async (n) => {
|
|
83
|
-
if (n.method === "notifications/channel_wake") {
|
|
84
|
-
config.notificationHandler(n.params);
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
await client.connect(transport);
|
|
89
|
-
const registerResp = await client.callTool({
|
|
90
|
-
name: "register_agent",
|
|
91
|
-
arguments: {
|
|
92
|
-
client: "custom",
|
|
93
|
-
client_name: "cross-agent-teams-channel",
|
|
94
|
-
model: "proxy",
|
|
95
|
-
role: "__channel_proxy__",
|
|
96
|
-
name: `channel-proxy-${process.pid}`,
|
|
97
|
-
team: "default",
|
|
98
|
-
claude_ui_pid: process.ppid,
|
|
99
|
-
delivery: {
|
|
100
|
-
kind: "claude-channel",
|
|
101
|
-
channel_session_id: config.channel_session_id
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
order.push("register_agent");
|
|
106
|
-
const regResult = await parseToolResult(registerResp);
|
|
107
|
-
if (!("agent_id" in regResult)) {
|
|
108
|
-
throw new Error(`register_agent failed: ${JSON.stringify(regResult)}`);
|
|
109
|
-
}
|
|
110
|
-
const subResp = await client.callTool({
|
|
111
|
-
name: "subscribe_channel_wake",
|
|
112
|
-
arguments: { channel_session_id: config.channel_session_id }
|
|
113
|
-
});
|
|
114
|
-
order.push("subscribe_channel_wake");
|
|
115
|
-
const subResult = await parseToolResult(subResp);
|
|
116
|
-
if (!("ok" in subResult) || subResult.ok !== true) {
|
|
117
|
-
throw new Error(`subscribe_channel_wake failed: ${JSON.stringify(subResult)}`);
|
|
118
|
-
}
|
|
119
|
-
return {
|
|
120
|
-
order,
|
|
121
|
-
lastSubscribeResult: subResult,
|
|
122
|
-
client,
|
|
123
|
-
transport,
|
|
124
|
-
close: async () => {
|
|
125
|
-
try {
|
|
126
|
-
await client.close();
|
|
127
|
-
} catch {
|
|
128
|
-
}
|
|
129
|
-
try {
|
|
130
|
-
await transport.close();
|
|
131
|
-
} catch {
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
async function runReconnectingProxy(config) {
|
|
137
|
-
let stopped = false;
|
|
138
|
-
let currentSeq = null;
|
|
139
|
-
async function waitForDisconnect(seq) {
|
|
140
|
-
const interval = config.healthCheckIntervalMs ?? 200;
|
|
141
|
-
let disconnected = false;
|
|
142
|
-
const closeHandler = () => {
|
|
143
|
-
disconnected = true;
|
|
144
|
-
};
|
|
145
|
-
const prevOnClose = seq.transport.onclose;
|
|
146
|
-
seq.transport.onclose = () => {
|
|
147
|
-
prevOnClose?.();
|
|
148
|
-
closeHandler();
|
|
149
|
-
};
|
|
150
|
-
while (!disconnected && !stopped) {
|
|
151
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
152
|
-
if (disconnected || stopped) break;
|
|
153
|
-
try {
|
|
154
|
-
await seq.client.callTool({ name: "echo", arguments: { msg: "hb" } });
|
|
155
|
-
} catch {
|
|
156
|
-
disconnected = true;
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const initialSeq = await runRegistrationSequence(config);
|
|
162
|
-
currentSeq = initialSeq;
|
|
163
|
-
if (config.onSequenceComplete) config.onSequenceComplete([...initialSeq.order]);
|
|
164
|
-
async function backgroundLoop() {
|
|
165
|
-
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;
|
|
171
|
-
try {
|
|
172
|
-
const next = await runRegistrationSequence(config);
|
|
173
|
-
currentSeq = next;
|
|
174
|
-
if (config.onSequenceComplete) config.onSequenceComplete([...next.order]);
|
|
175
|
-
} catch {
|
|
176
|
-
}
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
await waitForDisconnect(seq);
|
|
180
|
-
if (config.onDisconnect) config.onDisconnect();
|
|
181
|
-
try {
|
|
182
|
-
await seq.close();
|
|
183
|
-
} catch {
|
|
184
|
-
}
|
|
185
|
-
currentSeq = null;
|
|
186
|
-
if (stopped) break;
|
|
187
|
-
const wait = config.backoffInitialMs ?? 500;
|
|
188
|
-
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
|
-
}
|
|
197
|
-
}
|
|
198
|
-
void backgroundLoop();
|
|
199
|
-
return {
|
|
200
|
-
stop: async () => {
|
|
201
|
-
stopped = true;
|
|
202
|
-
if (currentSeq) {
|
|
203
|
-
try {
|
|
204
|
-
await currentSeq.close();
|
|
205
|
-
} catch {
|
|
206
|
-
}
|
|
207
|
-
}
|
|
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"
|
|
286
|
-
};
|
|
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
|
-
}
|
|
300
|
-
|
|
301
|
-
// src/channel-cli.ts
|
|
302
|
-
var DEFAULT_DAEMON_URL = "http://127.0.0.1:9100/mcp";
|
|
303
|
-
var CliArgError = class extends Error {
|
|
304
|
-
constructor(message) {
|
|
305
|
-
super(message);
|
|
306
|
-
this.name = "CliArgError";
|
|
307
|
-
}
|
|
308
|
-
};
|
|
309
|
-
function buildStartupHint(csid) {
|
|
310
|
-
const content = [
|
|
311
|
-
`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.`,
|
|
317
|
-
`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
|
-
`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
|
-
].join(" ");
|
|
320
|
-
return {
|
|
321
|
-
content,
|
|
322
|
-
meta: { source: "cross_agent_teams_mcp", kind: "startup_bind_hint" }
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
function parseCliArgs(argv, env = process.env) {
|
|
326
|
-
let daemonUrl;
|
|
327
|
-
for (let i = 0; i < argv.length; i++) {
|
|
328
|
-
const flag = argv[i];
|
|
329
|
-
const next = argv[i + 1];
|
|
330
|
-
switch (flag) {
|
|
331
|
-
case "--daemon-url":
|
|
332
|
-
daemonUrl = next;
|
|
333
|
-
i++;
|
|
334
|
-
break;
|
|
335
|
-
default:
|
|
336
|
-
break;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
if (!daemonUrl || daemonUrl.length === 0) {
|
|
340
|
-
daemonUrl = env.CROSS_AGENT_TEAMS_MCP_DAEMON_URL;
|
|
341
|
-
}
|
|
342
|
-
if (!daemonUrl || daemonUrl.length === 0) {
|
|
343
|
-
daemonUrl = DEFAULT_DAEMON_URL;
|
|
344
|
-
}
|
|
345
|
-
return { daemonUrl };
|
|
346
|
-
}
|
|
347
|
-
async function main(argv = process.argv.slice(2), env = process.env) {
|
|
348
|
-
let args;
|
|
349
|
-
try {
|
|
350
|
-
args = parseCliArgs(argv, env);
|
|
351
|
-
} catch (err) {
|
|
352
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
353
|
-
process.stderr.write(`cross-agent-teams-channel: ${msg}
|
|
354
|
-
`);
|
|
355
|
-
process.exit(2);
|
|
356
|
-
}
|
|
357
|
-
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
|
-
const hostServer = createProxyServer();
|
|
367
|
-
const stdioTransport = new StdioServerTransport();
|
|
368
|
-
let stdioReady = false;
|
|
369
|
-
let controller;
|
|
370
|
-
try {
|
|
371
|
-
controller = await runReconnectingProxy({
|
|
372
|
-
daemonUrl: args.daemonUrl,
|
|
373
|
-
channel_session_id: csid,
|
|
374
|
-
notificationHandler: (params) => {
|
|
375
|
-
relayChannelWake(hostServer, params);
|
|
376
|
-
},
|
|
377
|
-
onSequenceComplete: () => {
|
|
378
|
-
if (!stdioReady) return;
|
|
379
|
-
const hint = buildStartupHint(csid);
|
|
380
|
-
relayChannelWake(hostServer, hint);
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
} catch (err) {
|
|
384
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
385
|
-
process.stderr.write(`cross-agent-teams-channel: initial daemon registration failed: ${msg}
|
|
386
|
-
`);
|
|
387
|
-
process.exit(2);
|
|
388
|
-
}
|
|
389
|
-
installForwardingHandlers(hostServer, () => controller.getClient());
|
|
390
|
-
let stopped = false;
|
|
391
|
-
const shutdown = async () => {
|
|
392
|
-
if (stopped) return;
|
|
393
|
-
stopped = true;
|
|
394
|
-
try {
|
|
395
|
-
await controller.stop();
|
|
396
|
-
} catch {
|
|
397
|
-
}
|
|
398
|
-
try {
|
|
399
|
-
await hostServer.close();
|
|
400
|
-
} catch {
|
|
401
|
-
}
|
|
402
|
-
process.exit(0);
|
|
403
|
-
};
|
|
404
|
-
stdioTransport.onclose = () => {
|
|
405
|
-
void shutdown();
|
|
406
|
-
};
|
|
407
|
-
await hostServer.connect(stdioTransport);
|
|
408
|
-
stdioReady = true;
|
|
409
|
-
relayChannelWake(hostServer, buildStartupHint(csid));
|
|
410
|
-
process.on("SIGTERM", () => {
|
|
411
|
-
void shutdown();
|
|
412
|
-
});
|
|
413
|
-
process.on("SIGINT", () => {
|
|
414
|
-
void shutdown();
|
|
415
|
-
});
|
|
416
|
-
}
|
|
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
|
-
});
|
|
432
|
-
}
|
|
433
|
-
export {
|
|
434
|
-
CliArgError,
|
|
435
|
-
buildStartupHint,
|
|
436
|
-
main,
|
|
437
|
-
parseCliArgs
|
|
438
|
-
};
|
|
439
|
-
//# sourceMappingURL=channel-cli.js.map
|
package/dist/channel-cli.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
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 //\n // The initial register fires onSequenceComplete BEFORE stdio.connect below,\n // so we suppress the hint emission for that first sequence and re-emit it\n // manually after stdio is connected (when the host transport actually\n // exists). Reconnect-time onSequenceComplete continues to emit normally.\n let stdioReady = false\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 onSequenceComplete: () => {\n // Skip during the initial sequence (stdio not yet connected); the\n // post-connect manual relay below handles the first emit. On\n // subsequent reconnects this branch fires normally so the host can\n // re-observe csid.\n if (!stdioReady) return\n const hint = buildStartupHint(csid)\n relayChannelWake(hostServer, hint)\n }\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 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\n // Now that the host transport exists, mark ready and emit the initial\n // startup hint that was suppressed during the awaited first\n // runReconnectingProxy registration.\n stdioReady = true\n relayChannelWake(hostServer, buildStartupHint(csid))\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,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,168 +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
|
-
//
|
|
99
|
-
// The initial register fires onSequenceComplete BEFORE stdio.connect below,
|
|
100
|
-
// so we suppress the hint emission for that first sequence and re-emit it
|
|
101
|
-
// manually after stdio is connected (when the host transport actually
|
|
102
|
-
// exists). Reconnect-time onSequenceComplete continues to emit normally.
|
|
103
|
-
let stdioReady = false
|
|
104
|
-
let controller: Awaited<ReturnType<typeof runReconnectingProxy>>
|
|
105
|
-
try {
|
|
106
|
-
controller = await runReconnectingProxy({
|
|
107
|
-
daemonUrl: args.daemonUrl,
|
|
108
|
-
channel_session_id: csid,
|
|
109
|
-
notificationHandler: (params) => {
|
|
110
|
-
relayChannelWake(hostServer, params as { content: string; meta: Record<string, string> })
|
|
111
|
-
},
|
|
112
|
-
onSequenceComplete: () => {
|
|
113
|
-
// Skip during the initial sequence (stdio not yet connected); the
|
|
114
|
-
// post-connect manual relay below handles the first emit. On
|
|
115
|
-
// subsequent reconnects this branch fires normally so the host can
|
|
116
|
-
// re-observe csid.
|
|
117
|
-
if (!stdioReady) return
|
|
118
|
-
const hint = buildStartupHint(csid)
|
|
119
|
-
relayChannelWake(hostServer, hint)
|
|
120
|
-
}
|
|
121
|
-
})
|
|
122
|
-
} catch (err) {
|
|
123
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
124
|
-
process.stderr.write(`cross-agent-teams-channel: initial daemon registration failed: ${msg}\n`)
|
|
125
|
-
process.exit(2)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
installForwardingHandlers(hostServer, () => controller.getClient())
|
|
129
|
-
|
|
130
|
-
let stopped = false
|
|
131
|
-
const shutdown = async (): Promise<void> => {
|
|
132
|
-
if (stopped) return
|
|
133
|
-
stopped = true
|
|
134
|
-
try { await controller.stop() } catch { /* best-effort */ }
|
|
135
|
-
try { await hostServer.close() } catch { /* best-effort */ }
|
|
136
|
-
process.exit(0)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
stdioTransport.onclose = () => { void shutdown() }
|
|
140
|
-
|
|
141
|
-
await hostServer.connect(stdioTransport)
|
|
142
|
-
|
|
143
|
-
// Now that the host transport exists, mark ready and emit the initial
|
|
144
|
-
// startup hint that was suppressed during the awaited first
|
|
145
|
-
// runReconnectingProxy registration.
|
|
146
|
-
stdioReady = true
|
|
147
|
-
relayChannelWake(hostServer, buildStartupHint(csid))
|
|
148
|
-
|
|
149
|
-
process.on('SIGTERM', () => { void shutdown() })
|
|
150
|
-
process.on('SIGINT', () => { void shutdown() })
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function isInvokedAsEntry(): boolean {
|
|
154
|
-
const argv1 = process.argv[1]
|
|
155
|
-
if (!argv1) return false
|
|
156
|
-
try {
|
|
157
|
-
return realpathSync(argv1) === fileURLToPath(import.meta.url)
|
|
158
|
-
} catch {
|
|
159
|
-
return false
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (isInvokedAsEntry()) {
|
|
164
|
-
main().catch((e) => {
|
|
165
|
-
process.stderr.write(`cross-agent-teams-channel: ${e?.message ?? e}\n`)
|
|
166
|
-
process.exit(1)
|
|
167
|
-
})
|
|
168
|
-
}
|