@sleep2agi/agent-network 0.0.15 → 0.0.17
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 +9 -5
- package/dist/bin/cli.js +17 -16
- package/package.json +3 -2
- package/src/node-server.ts +458 -0
package/README.md
CHANGED
|
@@ -64,11 +64,11 @@ anet init profile <id> [options] # → .anet/profiles/<id>.json
|
|
|
64
64
|
|
|
65
65
|
```
|
|
66
66
|
~/.anet/config.json 全局(hub URL,一次性)
|
|
67
|
-
{workpath}/.anet/server.ts Channel 插件
|
|
67
|
+
{workpath}/.anet/node-server.ts Channel 插件
|
|
68
68
|
{workpath}/.anet/package.json 依赖声明
|
|
69
69
|
{workpath}/.anet/.env COMMHUB_URL
|
|
70
70
|
{workpath}/.anet/profiles/cmd.json 启动 profile
|
|
71
|
-
{workpath}/.mcp.json MCP 配置(commhub → .anet/server.ts)
|
|
71
|
+
{workpath}/.mcp.json MCP 配置(commhub → .anet/node-server.ts)
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
全部在项目目录内,不碰全局 `~/.claude/`。
|
|
@@ -92,10 +92,10 @@ anet init
|
|
|
92
92
|
```bash
|
|
93
93
|
cd ~/my-project
|
|
94
94
|
anet init project
|
|
95
|
-
# ✅ .anet/server.ts
|
|
95
|
+
# ✅ .anet/node-server.ts
|
|
96
96
|
# ✅ Dependencies installed
|
|
97
97
|
# CommHub URL: http://YOUR_IP:9200
|
|
98
|
-
# .mcp.json: commhub → .anet/server.ts
|
|
98
|
+
# .mcp.json: commhub → .anet/node-server.ts
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
#### anet init profile
|
|
@@ -206,7 +206,11 @@ const { CommHub } = require('@sleep2agi/agent-network');
|
|
|
206
206
|
|
|
207
207
|
| 版本 | 变更 |
|
|
208
208
|
|------|------|
|
|
209
|
-
| 0.0.
|
|
209
|
+
| 0.0.16 | server.ts → node-server.ts,从 npm 包内 copy(不依赖 GitHub 下载) |
|
|
210
|
+
| 0.0.15 | 自动去掉 hub URL 结尾斜杠 |
|
|
211
|
+
| 0.0.14 | init project 自动生成 CLAUDE.md |
|
|
212
|
+
| 0.0.13 | init 交互输入后不再卡住 |
|
|
213
|
+
| 0.0.12 | README 同步 |
|
|
210
214
|
| 0.0.11 | node config 加 anet_version 字段 |
|
|
211
215
|
| 0.0.10 | resumeAlias 字段,resume 按名字搜索 |
|
|
212
216
|
| 0.0.9 | start/resume 分离 |
|
package/dist/bin/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{createRequire as
|
|
3
|
-
`);W=
|
|
4
|
-
`)}function M(z){let B=
|
|
5
|
-
`)}function
|
|
2
|
+
import{createRequire as t}from"node:module";var p=Object.defineProperty;var c=(z)=>z;function d(z,B){this[z]=c.bind(null,B)}var l=(z,B)=>{for(var Q in B)p(z,Q,{get:B[Q],enumerable:!0,configurable:!0,set:d.bind(B,Q)})};var n=(z,B)=>()=>(z&&(B=z(z=0)),B);var w=t(import.meta.url);var j={};l(j,{default:()=>o,CommHub:()=>H});import{EventEmitter as r}from"events";import{hostname as C}from"os";var H,o;var P=n(()=>{H=class H extends r{url;alias;token;agent;resumeId;heartbeatInterval;reconnectDelay;heartbeatTimer;sseAbort;running=!1;constructor(z){super();if(this.url=z.url.replace(/\/$/,""),this.alias=z.alias,this.token=z.token,this.agent=z.agent||"sdk",this.resumeId=`sdk-${z.alias}-${Date.now().toString(36)}`,this.heartbeatInterval=z.heartbeatInterval??180000,this.reconnectDelay=z.reconnectDelay??3000,z.autoConnect!==!1)this.connect()}log(z){console.log(`[${new Date().toTimeString().slice(0,8)}] [commhub:${this.alias}] ${z}`)}async call(z,B){let Q={"Content-Type":"application/json"};if(this.token)Q.Authorization=`Bearer ${this.token}`;let X=await(await fetch(`${this.url}/mcp`,{method:"POST",headers:Q,body:JSON.stringify({jsonrpc:"2.0",id:Date.now(),method:"tools/call",params:{name:z,arguments:B}})})).json(),Z=X?.result?.content?.[0]?.text;return Z?JSON.parse(Z):X}async connect(){if(this.running)return;this.running=!0,await this.status("idle"),this.log("registered"),this.heartbeatTimer=setInterval(()=>{this.status("idle").catch((z)=>this.log(`heartbeat failed: ${z.message}`))},this.heartbeatInterval),this.connectSSE()}async disconnect(){if(this.running=!1,this.sseAbort?.abort(),this.heartbeatTimer)clearInterval(this.heartbeatTimer);await this.status("offline").catch(()=>{}),this.log("disconnected")}async send(z,B,Q="normal"){return this.call("send_task",{alias:z,task:B,priority:Q,from_session:this.alias})}async message(z,B){return this.call("send_message",{alias:z,message:B,from_session:this.alias})}async reply(z,B,Q="completed"){return this.call("reply",{task_id:z,text:B,status:Q})}async status(z,B){return this.call("report_status",{resume_id:this.resumeId,alias:this.alias,status:z,server:C(),hostname:C(),agent:this.agent,project_dir:process.cwd(),...B})}async getAllStatus(){return this.call("get_all_status",{})}async broadcast(z,B){return this.call("broadcast",{message:z,filter_server:B?.server,filter_status:B?.status})}async connectSSE(){let z=encodeURIComponent(this.alias),B=`${this.url}/events/${z}`,Q=this.reconnectDelay;while(this.running){try{this.sseAbort=new AbortController;let V={Accept:"text/event-stream"};if(this.token)V.Authorization=`Bearer ${this.token}`;let X=await fetch(B,{headers:V,signal:this.sseAbort.signal});if(!X.ok||!X.body){this.log(`SSE failed: ${X.status}`),await this.sleep(Q),Q=Math.min(Q*1.5,60000);continue}Q=this.reconnectDelay;let Z=X.body.getReader(),N=new TextDecoder,W="";while(this.running){let{done:Y,value:U}=await Z.read();if(Y)break;W+=N.decode(U,{stream:!0});let _=W.split(`
|
|
3
|
+
`);W=_.pop()||"";for(let R of _){if(!R.startsWith("data: "))continue;try{let O=JSON.parse(R.slice(6));if(O.type==="connected"){this.log("SSE connected"),this.emit("connected");continue}if(O.type==="new_task"||O.type==="new_message"||O.type==="broadcast")await this.processInbox()}catch{}}}}catch(V){if(V.name==="AbortError")break;this.emit("error",V),this.log(`SSE error: ${V.message}`)}if(this.running)this.emit("disconnected"),this.log(`SSE reconnecting in ${Q/1000}s...`),await this.sleep(Q),Q=Math.min(Q*1.5,60000)}}async processInbox(){try{let B=(await this.call("get_inbox",{alias:this.alias,limit:10}))?.messages||[];for(let Q of B)await this.call("ack_inbox",{alias:this.alias,message_id:Q.id}),this.log(`← ${Q.from_session}: ${Q.content.slice(0,60)}`),this.emit("task",Q),this.emit("message",Q)}catch(z){this.log(`inbox error: ${z.message}`)}}sleep(z){return new Promise((B)=>setTimeout(B,z))}};o=H});import{readFileSync as E,writeFileSync as K,existsSync as q,mkdirSync as G,readdirSync as S}from"fs";import{join as L}from"path";import{spawn as a}from"child_process";var $=process.argv.slice(2),A=$[0],T=process.env.HOME||process.env.USERPROFILE||"~";function y(){return L(T,".anet","config.json")}function k(){return L(process.cwd(),".anet","profiles")}function I(){let z=y();if(q(z))try{return JSON.parse(E(z,"utf-8"))}catch{}return{}}function i(z){let B=L(T,".anet");G(B,{recursive:!0}),K(L(B,"config.json"),JSON.stringify(z,null,2)+`
|
|
4
|
+
`)}function M(z){let B=L(k(),`${z}.json`);if(q(B))try{return JSON.parse(E(B,"utf-8"))}catch{}return null}function e(z,B){let Q=k();G(Q,{recursive:!0}),K(L(Q,`${z}.json`),JSON.stringify(B,null,2)+`
|
|
5
|
+
`)}function u(){let z=k();if(!q(z))return[];return S(z).filter((B)=>B.endsWith(".json")).map((B)=>B.replace(/\.json$/,""))}function F(){let z={_channels:[],_envs:[]};for(let B=0;B<$.length;B++){if($[B]==="--channel"&&$[B+1]){z._channels.push($[++B]);continue}if($[B]==="--env"&&$[B+1]){z._envs.push($[++B]);continue}if($[B].startsWith("--")&&$[B+1]&&!$[B+1].startsWith("--"))z[$[B].slice(2)]=$[++B]}return z}function v(){console.log(`
|
|
6
6
|
anet — AI Agent Network CLI
|
|
7
7
|
|
|
8
8
|
anet init Configure hub URL (global, once)
|
|
@@ -21,9 +21,10 @@ Quick start:
|
|
|
21
21
|
anet start 指挥室 # 新建
|
|
22
22
|
anet resume 指挥室 # 下次恢复
|
|
23
23
|
`)}async function s(){let z=F(),B=z.hub;if(!B)process.stdout.write("CommHub URL (e.g. http://YOUR_IP:9200): "),B=await new Promise((V)=>{process.stdin.setEncoding("utf-8"),process.stdin.once("data",(X)=>{process.stdin.unref(),V(X.toString().trim())})});if(!B)console.error("Error: hub URL required"),process.exit(1);B=B.replace(/\/+$/,"");try{let X=await(await fetch(`${B}/health`)).json();console.log(`✅ CommHub v${X.version} — ${X.sessions} sessions, ${X.sse_connections} SSE`)}catch(V){console.error(`❌ Cannot reach ${B}: ${V.message}`),process.exit(1)}let Q=I();if(Q.hub=B,z.token)Q.token=z.token;i(Q),console.log(`
|
|
24
|
-
Saved to ${
|
|
25
|
-
`)
|
|
26
|
-
`),console.log(
|
|
24
|
+
Saved to ${y()}`),console.log("Next: anet init project")}async function zz(){let B=I().hub;if(!B)console.error("Run 'anet init' first to configure hub URL"),process.exit(1);let Q=L(process.cwd(),".anet");G(Q,{recursive:!0});let V=L(Q,"node-server.ts");if(!q(V)){let U=new URL(".",import.meta.url).pathname,_=L(U,"..","..","src","node-server.ts");if(q(_)){let{copyFileSync:R}=await import("fs");R(_,V),console.log(" ✅ .anet/node-server.ts (from npm package)")}else try{let R=await fetch("https://raw.githubusercontent.com/sleep2agi/agent-comm-hub/main/channel/commhub-channel.ts");if(R.ok)K(V,await R.text()),console.log(" ✅ .anet/node-server.ts (downloaded)");else console.log(" ❌ Cannot find channel plugin. Copy manually from agent-comm-hub/channel/commhub-channel.ts")}catch{console.log(" ❌ Cannot find channel plugin. Copy manually from agent-comm-hub/channel/commhub-channel.ts")}}else console.log(" Channel plugin: exists");let X=L(Q,"package.json");if(!q(X)){K(X,JSON.stringify({private:!0,dependencies:{"@modelcontextprotocol/sdk":"^1.12.0"}},null,2)+`
|
|
25
|
+
`);try{let{execSync:U}=await import("child_process");U("bun install",{cwd:Q,stdio:"pipe"}),console.log(" ✅ Dependencies installed")}catch{console.log(" ⚠️ Run: cd .anet && bun install")}}let Z=L(Q,".env");K(Z,`COMMHUB_URL=${B}
|
|
26
|
+
`),console.log(`CommHub URL: ${B}`);let N=L(process.cwd(),".mcp.json"),W={};if(q(N))try{W=JSON.parse(E(N,"utf-8"))}catch{}if(!W.mcpServers?.commhub)W.mcpServers=W.mcpServers||{},W.mcpServers.commhub={type:"stdio",command:"bun",args:[".anet/node-server.ts"]},K(N,JSON.stringify(W,null,2)+`
|
|
27
|
+
`),console.log(".mcp.json: commhub → .anet/node-server.ts");else console.log(".mcp.json: commhub already set");let Y=L(process.cwd(),"CLAUDE.md");if(!q(Y))K(Y,`# Agent Network (CommHub)
|
|
27
28
|
|
|
28
29
|
## 通信方式
|
|
29
30
|
|
|
@@ -62,16 +63,16 @@ commhub_get_all_status()
|
|
|
62
63
|
- 回复指挥室用 commhub_send_task(不是 commhub_reply,reply 不推送)
|
|
63
64
|
- 不要猜 alias,用 get_all_status 查
|
|
64
65
|
`),console.log("CLAUDE.md: created");else console.log("CLAUDE.md: already exists");console.log(`
|
|
65
|
-
✅ Project ready. Next: anet init profile <id> --alias <名字> --channel server:commhub`)}function Bz(){let z
|
|
66
|
-
`),e(z,
|
|
67
|
-
✅ Profile "${z}" saved`),console.log(` alias: ${V}`),console.log(` channels: ${
|
|
68
|
-
Start: anet start ${z}`)}function
|
|
69
|
-
`),a("claude",X,{env:V,stdio:"inherit",shell:!0}).on("exit",(W)=>process.exit(W||0))}function
|
|
66
|
+
✅ Project ready. Next: anet init profile <id> --alias <名字> --channel server:commhub`)}function Bz(){let z=$[2];if(!z)console.error("Usage: anet init profile <id> --alias <名字> [--channel ...] [--env ...]"),process.exit(1);let B=I(),Q=F(),V=Q.alias||z,X=Q.hub||B.hub;if(!X)console.error("Run 'anet init' first to configure hub URL"),process.exit(1);let Z={};for(let _ of Q._envs){let R=_.indexOf("=");if(R>0)Z[_.slice(0,R)]=_.slice(R+1)}let N={anet_version:"0.0.11",...Q.name?{name:Q.name}:{},alias:V,hub:X,channels:Q._channels.length>0?Q._channels:["server:commhub"],env:Z,flags:{dangerouslySkipPermissions:!0,...Q["teammate-mode"]?{teammateMode:Q["teammate-mode"]}:{}},...Q.resume?{resume:Q.resume}:{},...Q["resume-alias"]?{resumeAlias:Q["resume-alias"]}:{}},W=L(T,".claude","channels","commhub"),Y=process.cwd().replace(/\//g,"-"),U=L(W,Y);if(G(U,{recursive:!0}),K(L(U,".env"),`COMMHUB_ALIAS=${V}
|
|
67
|
+
`),e(z,N),console.log(`
|
|
68
|
+
✅ Profile "${z}" saved`),console.log(` alias: ${V}`),console.log(` channels: ${N.channels.join(", ")}`),Object.keys(Z).length)console.log(` env: ${Object.keys(Z).join(", ")}`);console.log(`
|
|
69
|
+
Start: anet start ${z}`)}function f(z,B){let Q=M(z);if(!Q)console.error(`Profile "${z}" not found. Run: anet ls`),process.exit(1);let V={...process.env,COMMHUB_ALIAS:Q.alias};for(let[W,Y]of Object.entries(Q.env))V[W]=Y.replace(/^~/,T);let X=[];if(Q.flags.dangerouslySkipPermissions)X.push("--dangerously-skip-permissions");for(let W of Q.channels)if(W.startsWith("server:"))X.push("--dangerously-load-development-channels",W);else X.push("--channels",W);if(Q.flags.teammateMode)X.push("--teammate-mode",Q.flags.teammateMode);if(B==="resume"){let W=Q.resumeAlias||Q.name||Q.alias;X.push("--resume",W)}X.push("-n",Q.name||Q.alias),console.log(`[anet] ${B==="start"?"Starting new":"Resuming"} "${z}" (${Q.alias})...
|
|
70
|
+
`),a("claude",X,{env:V,stdio:"inherit",shell:!0}).on("exit",(W)=>process.exit(W||0))}function b(){let z=$[1];if(!z){h("start");return}f(z,"start")}function Qz(){let z=$[1];if(!z){h("resume");return}f(z,"resume")}function h(z){let B=u();if(B.length===0){console.log("No profiles. Run: anet init profile <id> --alias <名字>");return}console.log(`
|
|
70
71
|
Profiles:
|
|
71
72
|
`);for(let Q of B){let V=M(Q);console.log(` ${Q}${V?.name?` (${V.name})`:""} → ${V?.alias} [${V?.channels.join(", ")}]`)}console.log(`
|
|
72
73
|
anet ${z} <id>
|
|
73
|
-
`)}async function Vz(){let z=
|
|
74
|
+
`)}async function Vz(){let z=u();if(z.length>0){console.log(`
|
|
74
75
|
Profiles:
|
|
75
|
-
`);for(let W of z){let Y=M(W);console.log(` ${W}${Y?.name?` (${Y.name})`:""} → ${Y?.alias} [${Y?.channels.join(", ")}]`)}console.log()}let B=process.cwd(),Q=
|
|
76
|
-
`);return}let X=I(),Z=[],
|
|
77
|
-
`),console.log(" SESSION PID NETWORK"),console.log(" ──────────────────── ─────── ─────────────────────");for(let W of V){let Y=W.sessionId.slice(0,18)
|
|
76
|
+
`);for(let W of z){let Y=M(W);console.log(` ${W}${Y?.name?` (${Y.name})`:""} → ${Y?.alias} [${Y?.channels.join(", ")}]`)}console.log()}let B=process.cwd(),Q=L(T,".claude","sessions"),V=[];if(q(Q))for(let W of S(Q).filter((Y)=>Y.endsWith(".json")))try{let Y=JSON.parse(E(L(Q,W),"utf-8"));if(Y.cwd===B)V.push(Y)}catch{}if(V.length===0&&z.length===0){console.log("No sessions or profiles in this directory."),console.log(`Get started: anet init
|
|
77
|
+
`);return}let X=I(),Z=[],N={};if(X.hub)try{let[W,Y]=await Promise.all([fetch(`${X.hub}/api/status`).then((U)=>U.json()),fetch(`${X.hub}/health`).then((U)=>U.json())]);Z=W.sessions||[],N=Y.sse_sessions||{}}catch{}if(V.length>0){console.log(`Sessions (${B}):
|
|
78
|
+
`),console.log(" SESSION PID NETWORK"),console.log(" ──────────────────── ─────── ─────────────────────");for(let W of V){let Y=W.sessionId.slice(0,18),U=!1;try{process.kill(W.pid,0),U=!0}catch{}let _="(not in network)",R=B.replace(/\//g,"-"),O=L(T,".claude","channels","commhub",R,".env");if(q(O)){let D=E(O,"utf-8").match(/COMMHUB_ALIAS=(.+)/);if(D){let J=D[1].trim(),x=Z.find((m)=>m.alias===J),g=N[J]?"●":"○";_=x?`${J} ${x.status} ${g}`:`${J} (not registered)`}}console.log(` ${Y} ${(U?`${W.pid}`:`${W.pid}✕`).padEnd(7)} ${_}`)}console.log()}}async function Wz(){let z=I(),B=F(),Q=process.env.COMMHUB_URL||B.hub||z.hub||"http://127.0.0.1:9200",V=process.env.COMMHUB_ALIAS||B.alias;if(!V)console.error("Error: --alias required"),process.exit(1);let{CommHub:X}=await Promise.resolve().then(() => (P(),j)),Z=new X({url:Q,alias:V});Z.on("task",async(N)=>{console.log(`[${V}] ← ${N.from_session}: ${N.content.slice(0,100)}`),await Z.send(N.from_session,`[${V}] 收到: ${N.content.slice(0,200)}`)}),Z.on("connected",()=>console.log(`[${V}] Connected`)),Z.on("disconnected",()=>console.log(`[${V}] Reconnecting...`)),process.on("SIGINT",()=>Z.disconnect().then(()=>process.exit(0))),console.log(`[${V}] Listening on ${Q}`)}switch(A){case"init":if($[1]==="project")zz();else if($[1]==="profile")Bz();else s();break;case"start":b();break;case"resume":Qz();break;case"ls":case"list":Vz();break;case"run":Wz();break;case"--help":case"-h":case void 0:v();break;default:if(M(A))$.unshift("start"),b();else console.error(`Unknown: ${A}`),v(),process.exit(1)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/agent-network",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"description": "AI Agent Network — Server + Client + Setup in one package. SSE real-time communication for multi-agent orchestration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/src/client.js",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
22
|
-
"src/server.ts"
|
|
22
|
+
"src/server.ts",
|
|
23
|
+
"src/node-server.ts"
|
|
23
24
|
],
|
|
24
25
|
"scripts": {
|
|
25
26
|
"build": "bun build src/client.ts --outdir dist/src --target node --minify && bun build bin/cli.ts --outdir dist/bin --target node --minify --external @sleep2agi/commhub-server --external bun:sqlite --external '../../server/*' && tsc --emitDeclarationOnly --declaration --outDir dist",
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* CommHub Channel Plugin for Claude Code
|
|
4
|
+
*
|
|
5
|
+
* Alias resolution (priority order):
|
|
6
|
+
* 1. COMMHUB_ALIAS env var
|
|
7
|
+
* 2. Project .env: ~/.claude/channels/commhub/{project-path}/.env
|
|
8
|
+
* 3. tmux session name
|
|
9
|
+
* 4. hostname
|
|
10
|
+
*
|
|
11
|
+
* Shared config from: ~/.claude/channels/commhub/.env
|
|
12
|
+
* COMMHUB_URL, COMMHUB_TOKEN
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { hostname } from "os";
|
|
18
|
+
import { execSync } from "child_process";
|
|
19
|
+
|
|
20
|
+
// ── .env loader helper ────────────────────────────────
|
|
21
|
+
function loadEnvFile(path: string): void {
|
|
22
|
+
if (!existsSync(path)) return;
|
|
23
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
26
|
+
const eq = trimmed.indexOf("=");
|
|
27
|
+
if (eq < 0) continue;
|
|
28
|
+
const key = trimmed.slice(0, eq).trim();
|
|
29
|
+
const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
|
|
30
|
+
if (!process.env[key]) process.env[key] = val;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Load shared config ────────────────────────────────
|
|
35
|
+
const HOME = process.env.HOME || "~";
|
|
36
|
+
const COMMHUB_DIR = join(HOME, ".claude/channels/commhub");
|
|
37
|
+
loadEnvFile(join(COMMHUB_DIR, ".env"));
|
|
38
|
+
|
|
39
|
+
// ── Load project-specific config ──────────────────────
|
|
40
|
+
// /home/vansin/vincent → -home-vansin-vincent
|
|
41
|
+
const projectPath = process.cwd().replace(/\//g, "-");
|
|
42
|
+
loadEnvFile(join(COMMHUB_DIR, projectPath, ".env"));
|
|
43
|
+
|
|
44
|
+
// ── Get tmux session name ─────────────────────────────
|
|
45
|
+
function getTmuxSessionName(): string {
|
|
46
|
+
try {
|
|
47
|
+
return execSync("tmux display-message -p '#S'", { encoding: "utf-8", timeout: 2000 }).trim();
|
|
48
|
+
} catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Resolve config ────────────────────────────────────
|
|
54
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
55
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
56
|
+
import {
|
|
57
|
+
ListToolsRequestSchema,
|
|
58
|
+
CallToolRequestSchema,
|
|
59
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
60
|
+
|
|
61
|
+
const COMMHUB_URL = process.env.COMMHUB_URL || "http://127.0.0.1:9200";
|
|
62
|
+
const TMUX_NAME = process.env.COMMHUB_TMUX || getTmuxSessionName();
|
|
63
|
+
const ALIAS = process.env.COMMHUB_ALIAS || TMUX_NAME || hostname();
|
|
64
|
+
const RESUME_ID = process.env.COMMHUB_RESUME_ID || process.env.CLAUDE_RESUME_ID || crypto.randomUUID();
|
|
65
|
+
const AUTH_TOKEN = process.env.COMMHUB_TOKEN || "";
|
|
66
|
+
|
|
67
|
+
function log(msg: string) {
|
|
68
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
69
|
+
process.stderr.write(`[${ts}] [commhub] ${msg}\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sleep(ms: number): Promise<void> {
|
|
73
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
log(`ENV: URL=${COMMHUB_URL} ALIAS=${ALIAS} RESUME_ID=${RESUME_ID.slice(0, 8)}... TMUX=${TMUX_NAME || "none"} CWD=${process.cwd()} PROJECT_ENV=${projectPath}`);
|
|
77
|
+
|
|
78
|
+
// ── MCP Server with Channel capability ──────────────
|
|
79
|
+
const mcp = new Server(
|
|
80
|
+
{
|
|
81
|
+
name: "commhub-channel",
|
|
82
|
+
version: "0.3.0",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
capabilities: {
|
|
86
|
+
experimental: { "claude/channel": {} },
|
|
87
|
+
tools: {},
|
|
88
|
+
},
|
|
89
|
+
instructions: [
|
|
90
|
+
`Messages from CommHub arrive as <channel source="commhub" task_id="..." priority="..." from="...">`,
|
|
91
|
+
`These are tasks dispatched by the hub or other sessions via the CommHub Server.`,
|
|
92
|
+
`Reply using the commhub_reply tool to report status or results back.`,
|
|
93
|
+
`You can also use commhub_report_status to update your session status.`,
|
|
94
|
+
`Session alias: ${ALIAS}`,
|
|
95
|
+
].join("\n"),
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// ── Tools ───────────────────────────────────────────
|
|
100
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
101
|
+
tools: [
|
|
102
|
+
{
|
|
103
|
+
name: "commhub_reply",
|
|
104
|
+
description: "Reply to a CommHub task — report completion or send a message back to the hub.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object" as const,
|
|
107
|
+
properties: {
|
|
108
|
+
task_id: { type: "string", description: "The task_id from the channel message (or 'hub' for general)" },
|
|
109
|
+
text: { type: "string", description: "Reply text / result summary" },
|
|
110
|
+
status: {
|
|
111
|
+
type: "string",
|
|
112
|
+
enum: ["completed", "blocked", "error", "in_progress"],
|
|
113
|
+
description: "Task status",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
required: ["text"],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "commhub_report_status",
|
|
121
|
+
description: "Update this session's status in CommHub (working/idle/blocked/error). Returns inbox_count.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object" as const,
|
|
124
|
+
properties: {
|
|
125
|
+
status: {
|
|
126
|
+
type: "string",
|
|
127
|
+
enum: ["working", "idle", "blocked", "error"],
|
|
128
|
+
},
|
|
129
|
+
task: { type: "string", description: "Current task description" },
|
|
130
|
+
progress: { type: "number", description: "Progress 0-100" },
|
|
131
|
+
},
|
|
132
|
+
required: ["status"],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "commhub_send_task",
|
|
137
|
+
description: "Send a task to another session via CommHub.",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: "object" as const,
|
|
140
|
+
properties: {
|
|
141
|
+
alias: { type: "string", description: "Target session alias" },
|
|
142
|
+
task: { type: "string", description: "Task content" },
|
|
143
|
+
priority: { type: "string", enum: ["high", "normal", "low"], description: "Priority (default: normal)" },
|
|
144
|
+
},
|
|
145
|
+
required: ["alias", "task"],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "commhub_send_message",
|
|
150
|
+
description: "Send a message to another session (no task lifecycle, just chat). Use for replies and status updates.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object" as const,
|
|
153
|
+
properties: {
|
|
154
|
+
alias: { type: "string", description: "Target session alias" },
|
|
155
|
+
message: { type: "string", description: "Message content" },
|
|
156
|
+
},
|
|
157
|
+
required: ["alias", "message"],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "commhub_get_all_status",
|
|
162
|
+
description: "Get status of all sessions from CommHub.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: "object" as const,
|
|
165
|
+
properties: {},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
// Helper: call CommHub MCP endpoint
|
|
172
|
+
async function callCommHub(toolName: string, args: Record<string, unknown>): Promise<any> {
|
|
173
|
+
const initRes = await fetch(`${COMMHUB_URL}/mcp`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: {
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
Accept: "application/json, text/event-stream",
|
|
178
|
+
...(AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {}),
|
|
179
|
+
},
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
jsonrpc: "2.0",
|
|
182
|
+
id: 1,
|
|
183
|
+
method: "initialize",
|
|
184
|
+
params: {
|
|
185
|
+
protocolVersion: "2025-03-26",
|
|
186
|
+
capabilities: {},
|
|
187
|
+
clientInfo: { name: "commhub-channel", version: "0.3.0" },
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
if (!initRes.ok) {
|
|
192
|
+
const errText = await initRes.text();
|
|
193
|
+
log(`CommHub init failed: ${initRes.status} ${errText.slice(0, 100)}`);
|
|
194
|
+
return { ok: false, error: `init failed: ${initRes.status}` };
|
|
195
|
+
}
|
|
196
|
+
await initRes.text();
|
|
197
|
+
|
|
198
|
+
const res = await fetch(`${COMMHUB_URL}/mcp`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: {
|
|
201
|
+
"Content-Type": "application/json",
|
|
202
|
+
Accept: "application/json, text/event-stream",
|
|
203
|
+
...(AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {}),
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
jsonrpc: "2.0",
|
|
207
|
+
id: 2,
|
|
208
|
+
method: "tools/call",
|
|
209
|
+
params: { name: toolName, arguments: args },
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const text = await res.text();
|
|
214
|
+
const dataLine = text.split("\n").find((l) => l.startsWith("data: "));
|
|
215
|
+
if (dataLine) {
|
|
216
|
+
const json = JSON.parse(dataLine.slice(6));
|
|
217
|
+
return json?.result?.content?.[0]?.text ? JSON.parse(json.result.content[0].text) : json;
|
|
218
|
+
}
|
|
219
|
+
return { ok: false, error: "no response" };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
223
|
+
const { name, arguments: args } = req.params;
|
|
224
|
+
|
|
225
|
+
if (name === "commhub_reply") {
|
|
226
|
+
const { task_id, text, status } = args as any;
|
|
227
|
+
if (status === "completed") {
|
|
228
|
+
const result = await callCommHub("report_completion", {
|
|
229
|
+
alias: ALIAS,
|
|
230
|
+
task: task_id || "task",
|
|
231
|
+
result: text,
|
|
232
|
+
});
|
|
233
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
234
|
+
}
|
|
235
|
+
const result = await callCommHub("report_status", {
|
|
236
|
+
resume_id: RESUME_ID,
|
|
237
|
+
alias: ALIAS,
|
|
238
|
+
status: status === "blocked" ? "blocked" : status === "error" ? "error" : "working",
|
|
239
|
+
task: text.slice(0, 200),
|
|
240
|
+
output: text,
|
|
241
|
+
});
|
|
242
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (name === "commhub_report_status") {
|
|
246
|
+
const { status, task, progress } = args as any;
|
|
247
|
+
const result = await callCommHub("report_status", {
|
|
248
|
+
resume_id: RESUME_ID,
|
|
249
|
+
alias: ALIAS,
|
|
250
|
+
status,
|
|
251
|
+
task,
|
|
252
|
+
progress,
|
|
253
|
+
});
|
|
254
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (name === "commhub_send_task") {
|
|
258
|
+
const { alias, task, priority } = args as any;
|
|
259
|
+
const result = await callCommHub("send_task", {
|
|
260
|
+
alias,
|
|
261
|
+
task,
|
|
262
|
+
priority: priority || "normal",
|
|
263
|
+
from_session: ALIAS,
|
|
264
|
+
});
|
|
265
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (name === "commhub_send_message") {
|
|
269
|
+
const { alias, message } = args as any;
|
|
270
|
+
const result = await callCommHub("send_message", {
|
|
271
|
+
alias,
|
|
272
|
+
message,
|
|
273
|
+
from_session: ALIAS,
|
|
274
|
+
});
|
|
275
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (name === "commhub_get_all_status") {
|
|
279
|
+
const result = await callCommHub("get_all_status", {});
|
|
280
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "unknown tool" }) }] };
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── SSE Listener: subscribe to /events/:alias ─────
|
|
287
|
+
async function connectSSE() {
|
|
288
|
+
const url = `${COMMHUB_URL}/events/${encodeURIComponent(ALIAS)}`;
|
|
289
|
+
const headers: Record<string, string> = {};
|
|
290
|
+
if (AUTH_TOKEN) headers.Authorization = `Bearer ${AUTH_TOKEN}`;
|
|
291
|
+
|
|
292
|
+
log(`connecting to ${url}`);
|
|
293
|
+
|
|
294
|
+
while (true) {
|
|
295
|
+
try {
|
|
296
|
+
const res = await fetch(url, { headers });
|
|
297
|
+
if (!res.ok) {
|
|
298
|
+
log(`SSE error: ${res.status} ${res.statusText}`);
|
|
299
|
+
await sleep(5000);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const reader = res.body!.getReader();
|
|
304
|
+
const decoder = new TextDecoder();
|
|
305
|
+
let buffer = "";
|
|
306
|
+
|
|
307
|
+
while (true) {
|
|
308
|
+
const { done, value } = await reader.read();
|
|
309
|
+
if (done) break;
|
|
310
|
+
|
|
311
|
+
buffer += decoder.decode(value, { stream: true });
|
|
312
|
+
const lines = buffer.split("\n\n");
|
|
313
|
+
buffer = lines.pop() || "";
|
|
314
|
+
|
|
315
|
+
for (const block of lines) {
|
|
316
|
+
const dataLine = block.split("\n").find((l) => l.startsWith("data: "));
|
|
317
|
+
if (!dataLine) continue;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const event = JSON.parse(dataLine.slice(6));
|
|
321
|
+
await handleSSEEvent(event);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
log(`parse error: ${e}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
log("SSE stream ended, reconnecting...");
|
|
329
|
+
} catch (err) {
|
|
330
|
+
log(`SSE connection error: ${err}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await sleep(3000);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function handleSSEEvent(event: any) {
|
|
338
|
+
if (event.type === "connected") {
|
|
339
|
+
log(`SSE connected as "${ALIAS}"`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (event.type === "new_message") {
|
|
344
|
+
log(`← message from ${event.from}: ${(event.message as string).slice(0, 60)}`);
|
|
345
|
+
|
|
346
|
+
await mcp.notification({
|
|
347
|
+
method: "notifications/claude/channel",
|
|
348
|
+
params: {
|
|
349
|
+
content: event.message,
|
|
350
|
+
meta: {
|
|
351
|
+
sender: event.from || "hub",
|
|
352
|
+
sender_id: "commhub",
|
|
353
|
+
priority: "normal",
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Auto-ack the message in inbox
|
|
359
|
+
if (event.message_id) {
|
|
360
|
+
await callCommHub("ack_inbox", { alias: ALIAS, message_id: event.message_id });
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (event.type === "new_task" || event.type === "broadcast") {
|
|
366
|
+
log(`← ${event.type}: inbox_count=${event.inbox_count} priority=${event.priority || "normal"}`);
|
|
367
|
+
|
|
368
|
+
const inbox = await callCommHub("get_inbox", {
|
|
369
|
+
alias: ALIAS,
|
|
370
|
+
limit: 5,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
if (inbox?.ok && inbox.messages?.length > 0) {
|
|
374
|
+
for (const msg of inbox.messages) {
|
|
375
|
+
const meta: Record<string, string> = {
|
|
376
|
+
sender: msg.from_session || "hub",
|
|
377
|
+
sender_id: "commhub",
|
|
378
|
+
task_id: msg.id,
|
|
379
|
+
priority: msg.priority || "normal",
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
await mcp.notification({
|
|
383
|
+
method: "notifications/claude/channel",
|
|
384
|
+
params: {
|
|
385
|
+
content: msg.content,
|
|
386
|
+
meta,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
log(`→ injected task ${msg.id.slice(0, 8)} from ${msg.from_session}: ${(msg.content as string).slice(0, 60)}`);
|
|
391
|
+
|
|
392
|
+
await callCommHub("ack_inbox", {
|
|
393
|
+
alias: ALIAS,
|
|
394
|
+
message_id: msg.id,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Main ────────────────────────────────────────────
|
|
402
|
+
async function main() {
|
|
403
|
+
const transport = new StdioServerTransport();
|
|
404
|
+
await mcp.connect(transport);
|
|
405
|
+
log("MCP stdio connected");
|
|
406
|
+
|
|
407
|
+
log("starting SSE listener...");
|
|
408
|
+
connectSSE().catch((err) => log(`SSE fatal: ${err}`));
|
|
409
|
+
|
|
410
|
+
callCommHub("report_status", {
|
|
411
|
+
resume_id: RESUME_ID,
|
|
412
|
+
alias: ALIAS,
|
|
413
|
+
status: "idle",
|
|
414
|
+
server: hostname(),
|
|
415
|
+
hostname: hostname(),
|
|
416
|
+
agent: "claude-code",
|
|
417
|
+
project_dir: process.cwd(),
|
|
418
|
+
tmux_name: TMUX_NAME || undefined,
|
|
419
|
+
})
|
|
420
|
+
.then(() => log(`registered as "${ALIAS}" (${RESUME_ID.slice(0, 8)})`))
|
|
421
|
+
.catch((e) => log(`warning: could not register: ${e}`));
|
|
422
|
+
|
|
423
|
+
// Heartbeat: report_status every 3 minutes to prevent offline timeout
|
|
424
|
+
setInterval(() => {
|
|
425
|
+
callCommHub("report_status", {
|
|
426
|
+
resume_id: RESUME_ID,
|
|
427
|
+
alias: ALIAS,
|
|
428
|
+
status: "idle",
|
|
429
|
+
server: hostname(),
|
|
430
|
+
hostname: hostname(),
|
|
431
|
+
agent: "claude-code",
|
|
432
|
+
project_dir: process.cwd(),
|
|
433
|
+
tmux_name: TMUX_NAME || undefined,
|
|
434
|
+
}).catch((e) => log(`heartbeat failed: ${e}`));
|
|
435
|
+
}, 3 * 60 * 1000);
|
|
436
|
+
|
|
437
|
+
log("ready — waiting for events");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
main().catch((err) => {
|
|
441
|
+
log(`fatal: ${err}`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
async function gracefulShutdown() {
|
|
446
|
+
log("shutting down, reporting offline...");
|
|
447
|
+
await callCommHub("report_status", {
|
|
448
|
+
resume_id: RESUME_ID,
|
|
449
|
+
alias: ALIAS,
|
|
450
|
+
status: "error",
|
|
451
|
+
task: "session disconnected",
|
|
452
|
+
}).catch(() => {});
|
|
453
|
+
process.exit(0);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
process.stdin.on("end", () => gracefulShutdown());
|
|
457
|
+
process.on("SIGTERM", () => gracefulShutdown());
|
|
458
|
+
process.on("SIGINT", () => gracefulShutdown());
|