@sleep2agi/agent-network 0.1.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -101
- package/dist/bin/cli.js +146 -234
- package/dist/src/client.js +2 -2
- package/package.json +5 -4
- package/src/node-server.ts +472 -0
package/dist/src/client.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
2
|
-
`);F=
|
|
1
|
+
import{EventEmitter as X}from"events";import{hostname as R}from"os";class T extends X{url;alias;token;agent;resumeId;heartbeatInterval;reconnectDelay;heartbeatTimer;sseAbort;running=!1;constructor(j){super();if(this.url=j.url.replace(/\/$/,""),this.alias=j.alias,this.token=j.token,this.agent=j.agent||"sdk",this.resumeId=`sdk-${j.alias}-${Date.now().toString(36)}`,this.heartbeatInterval=j.heartbeatInterval??180000,this.reconnectDelay=j.reconnectDelay??3000,j.autoConnect!==!1)this.connect()}log(j){console.log(`[${new Date().toTimeString().slice(0,8)}] [commhub:${this.alias}] ${j}`)}async call(j,z){let q={"Content-Type":"application/json",Accept:"application/json, text/event-stream"};if(this.token)q.Authorization=`Bearer ${this.token}`;let D=await(await fetch(`${this.url}/mcp`,{method:"POST",headers:q,body:JSON.stringify({jsonrpc:"2.0",id:Date.now(),method:"tools/call",params:{name:j,arguments:z}})})).text(),G=D.match(/data: (.+)/),L=G?JSON.parse(G[1]):JSON.parse(D),F=L?.result?.content?.[0]?.text;return F?JSON.parse(F):L}async connect(){if(this.running)return;this.running=!0,await this.status("idle"),this.log("registered"),this.heartbeatTimer=setInterval(()=>{this.status("idle").catch((j)=>this.log(`heartbeat failed: ${j.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(j,z,q="normal"){return this.call("send_task",{alias:j,task:z,priority:q,from_session:this.alias})}async message(j,z){return this.call("send_message",{alias:j,message:z,from_session:this.alias})}async reply(j,z,q="completed"){return this.call("reply",{task_id:j,text:z,status:q})}async status(j,z){return this.call("report_status",{resume_id:this.resumeId,alias:this.alias,status:j,server:R(),hostname:R(),agent:this.agent,project_dir:process.cwd(),...z})}async getAllStatus(){return this.call("get_all_status",{})}async broadcast(j,z){return this.call("broadcast",{message:j,filter_server:z?.server,filter_status:z?.status})}async connectSSE(){let j=encodeURIComponent(this.alias),z=`${this.url}/events/${j}`,q=this.reconnectDelay;while(this.running){try{this.sseAbort=new AbortController;let B={Accept:"text/event-stream"};if(this.token)B.Authorization=`Bearer ${this.token}`;let D=await fetch(z,{headers:B,signal:this.sseAbort.signal});if(!D.ok||!D.body){this.log(`SSE failed: ${D.status}`),await this.sleep(q),q=Math.min(q*1.5,60000);continue}q=this.reconnectDelay;let G=D.body.getReader(),L=new TextDecoder,F="";while(this.running){let{done:V,value:W}=await G.read();if(V)break;F+=L.decode(W,{stream:!0});let P=F.split(`
|
|
2
|
+
`);F=P.pop()||"";for(let Q of P){if(!Q.startsWith("data: "))continue;try{let M=JSON.parse(Q.slice(6));if(M.type==="connected"){this.log("SSE connected"),this.emit("connected");continue}if(M.type==="new_task"||M.type==="new_message"||M.type==="broadcast")await this.processInbox()}catch{}}}}catch(B){if(B.name==="AbortError")break;this.emit("error",B),this.log(`SSE error: ${B.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 z=(await this.call("get_inbox",{alias:this.alias,limit:10}))?.messages||[];for(let q of z)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(j){this.log(`inbox error: ${j.message}`)}}sleep(j){return new Promise((z)=>setTimeout(z,j))}}var $=T;export{$ as default,T as CommHub};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/agent-network",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "1.0.1",
|
|
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",
|
|
@@ -15,14 +15,15 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"bin": {
|
|
18
|
-
"
|
|
18
|
+
"anet": "dist/bin/cli.js"
|
|
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
|
-
"build": "bun build src/client.ts bin/cli.ts --outdir dist --target node --minify && tsc --emitDeclarationOnly --declaration --outDir dist",
|
|
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",
|
|
26
27
|
"prepublishOnly": "npm run build"
|
|
27
28
|
},
|
|
28
29
|
"keywords": [
|
|
@@ -0,0 +1,472 @@
|
|
|
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
|
+
// ── Load ~/.anet/config.json for token fallback ──────
|
|
62
|
+
function loadAnetConfig(): Record<string, string> {
|
|
63
|
+
try {
|
|
64
|
+
const p = join(HOME, ".anet", "config.json");
|
|
65
|
+
if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8"));
|
|
66
|
+
} catch {}
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
const ANET_CONFIG = loadAnetConfig();
|
|
70
|
+
|
|
71
|
+
const COMMHUB_URL = process.env.COMMHUB_URL || ANET_CONFIG.hub || "http://127.0.0.1:9200";
|
|
72
|
+
const TMUX_NAME = process.env.COMMHUB_TMUX || getTmuxSessionName();
|
|
73
|
+
const ALIAS = process.env.COMMHUB_ALIAS || TMUX_NAME || hostname();
|
|
74
|
+
const RESUME_ID = process.env.COMMHUB_RESUME_ID || process.env.CLAUDE_RESUME_ID || crypto.randomUUID();
|
|
75
|
+
const AUTH_TOKEN = process.env.COMMHUB_TOKEN || ANET_CONFIG.token || "";
|
|
76
|
+
|
|
77
|
+
function log(msg: string) {
|
|
78
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
79
|
+
process.stderr.write(`[${ts}] [commhub] ${msg}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sleep(ms: number): Promise<void> {
|
|
83
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
log(`ENV: URL=${COMMHUB_URL} ALIAS=${ALIAS} RESUME_ID=${RESUME_ID.slice(0, 8)}... TMUX=${TMUX_NAME || "none"} CWD=${process.cwd()} PROJECT_ENV=${projectPath}`);
|
|
87
|
+
|
|
88
|
+
// ── MCP Server with Channel capability ──────────────
|
|
89
|
+
// name 不要拼 alias!Claude Code 用 meta.user 自动加 "· xxx" 后缀
|
|
90
|
+
// 参考: telegram 插件 name 也只是 "telegram",不是 "telegram · vansinhu"
|
|
91
|
+
const mcp = new Server(
|
|
92
|
+
{
|
|
93
|
+
name: "commhub-channel",
|
|
94
|
+
version: "0.3.0",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
capabilities: {
|
|
98
|
+
experimental: { "claude/channel": {} },
|
|
99
|
+
tools: {},
|
|
100
|
+
},
|
|
101
|
+
instructions: [
|
|
102
|
+
`Messages from CommHub arrive as <channel source="commhub" task_id="..." priority="..." from="...">`,
|
|
103
|
+
`These are tasks dispatched by the hub or other sessions via the CommHub Server.`,
|
|
104
|
+
`Reply using the commhub_reply tool to report status or results back.`,
|
|
105
|
+
`You can also use commhub_report_status to update your session status.`,
|
|
106
|
+
`Session alias: ${ALIAS}`,
|
|
107
|
+
].join("\n"),
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// ── Tools ───────────────────────────────────────────
|
|
112
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
113
|
+
tools: [
|
|
114
|
+
{
|
|
115
|
+
name: "commhub_reply",
|
|
116
|
+
description: "Reply to a CommHub task — report completion or send a message back to the hub.",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: "object" as const,
|
|
119
|
+
properties: {
|
|
120
|
+
task_id: { type: "string", description: "The task_id from the channel message (or 'hub' for general)" },
|
|
121
|
+
text: { type: "string", description: "Reply text / result summary" },
|
|
122
|
+
status: {
|
|
123
|
+
type: "string",
|
|
124
|
+
enum: ["completed", "blocked", "error", "in_progress"],
|
|
125
|
+
description: "Task status",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
required: ["text"],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "commhub_report_status",
|
|
133
|
+
description: "Update this session's status in CommHub (working/idle/blocked/error). Returns inbox_count.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: "object" as const,
|
|
136
|
+
properties: {
|
|
137
|
+
status: {
|
|
138
|
+
type: "string",
|
|
139
|
+
enum: ["working", "idle", "blocked", "error"],
|
|
140
|
+
},
|
|
141
|
+
task: { type: "string", description: "Current task description" },
|
|
142
|
+
progress: { type: "number", description: "Progress 0-100" },
|
|
143
|
+
},
|
|
144
|
+
required: ["status"],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "commhub_send_task",
|
|
149
|
+
description: "Send a task to another session via CommHub.",
|
|
150
|
+
inputSchema: {
|
|
151
|
+
type: "object" as const,
|
|
152
|
+
properties: {
|
|
153
|
+
alias: { type: "string", description: "Target session alias" },
|
|
154
|
+
task: { type: "string", description: "Task content" },
|
|
155
|
+
priority: { type: "string", enum: ["high", "normal", "low"], description: "Priority (default: normal)" },
|
|
156
|
+
},
|
|
157
|
+
required: ["alias", "task"],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "commhub_send_message",
|
|
162
|
+
description: "Send a message to another session (no task lifecycle, just chat). Use for replies and status updates.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: "object" as const,
|
|
165
|
+
properties: {
|
|
166
|
+
alias: { type: "string", description: "Target session alias" },
|
|
167
|
+
message: { type: "string", description: "Message content" },
|
|
168
|
+
},
|
|
169
|
+
required: ["alias", "message"],
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "commhub_get_all_status",
|
|
174
|
+
description: "Get status of all sessions from CommHub.",
|
|
175
|
+
inputSchema: {
|
|
176
|
+
type: "object" as const,
|
|
177
|
+
properties: {},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
// Helper: call CommHub MCP endpoint
|
|
184
|
+
async function callCommHub(toolName: string, args: Record<string, unknown>): Promise<any> {
|
|
185
|
+
const initRes = await fetch(`${COMMHUB_URL}/mcp`, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: {
|
|
188
|
+
"Content-Type": "application/json",
|
|
189
|
+
Accept: "application/json, text/event-stream",
|
|
190
|
+
...(AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {}),
|
|
191
|
+
},
|
|
192
|
+
body: JSON.stringify({
|
|
193
|
+
jsonrpc: "2.0",
|
|
194
|
+
id: 1,
|
|
195
|
+
method: "initialize",
|
|
196
|
+
params: {
|
|
197
|
+
protocolVersion: "2025-03-26",
|
|
198
|
+
capabilities: {},
|
|
199
|
+
clientInfo: { name: "commhub-channel", version: "0.3.0" },
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
if (!initRes.ok) {
|
|
204
|
+
const errText = await initRes.text();
|
|
205
|
+
log(`CommHub init failed: ${initRes.status} ${errText.slice(0, 100)}`);
|
|
206
|
+
return { ok: false, error: `init failed: ${initRes.status}` };
|
|
207
|
+
}
|
|
208
|
+
await initRes.text();
|
|
209
|
+
|
|
210
|
+
const res = await fetch(`${COMMHUB_URL}/mcp`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: {
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
Accept: "application/json, text/event-stream",
|
|
215
|
+
...(AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {}),
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
jsonrpc: "2.0",
|
|
219
|
+
id: 2,
|
|
220
|
+
method: "tools/call",
|
|
221
|
+
params: { name: toolName, arguments: args },
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const text = await res.text();
|
|
226
|
+
const dataLine = text.split("\n").find((l) => l.startsWith("data: "));
|
|
227
|
+
if (dataLine) {
|
|
228
|
+
const json = JSON.parse(dataLine.slice(6));
|
|
229
|
+
return json?.result?.content?.[0]?.text ? JSON.parse(json.result.content[0].text) : json;
|
|
230
|
+
}
|
|
231
|
+
return { ok: false, error: "no response" };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
235
|
+
const { name, arguments: args } = req.params;
|
|
236
|
+
|
|
237
|
+
if (name === "commhub_reply") {
|
|
238
|
+
const { task_id, text, status } = args as any;
|
|
239
|
+
if (status === "completed") {
|
|
240
|
+
const result = await callCommHub("report_completion", {
|
|
241
|
+
alias: ALIAS,
|
|
242
|
+
task: task_id || "task",
|
|
243
|
+
result: text,
|
|
244
|
+
});
|
|
245
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
246
|
+
}
|
|
247
|
+
const result = await callCommHub("report_status", {
|
|
248
|
+
resume_id: RESUME_ID,
|
|
249
|
+
alias: ALIAS,
|
|
250
|
+
status: status === "blocked" ? "blocked" : status === "error" ? "error" : "working",
|
|
251
|
+
task: text.slice(0, 200),
|
|
252
|
+
output: text,
|
|
253
|
+
});
|
|
254
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (name === "commhub_report_status") {
|
|
258
|
+
const { status, task, progress } = args as any;
|
|
259
|
+
const result = await callCommHub("report_status", {
|
|
260
|
+
resume_id: RESUME_ID,
|
|
261
|
+
alias: ALIAS,
|
|
262
|
+
status,
|
|
263
|
+
task,
|
|
264
|
+
progress,
|
|
265
|
+
});
|
|
266
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (name === "commhub_send_task") {
|
|
270
|
+
const { alias, task, priority } = args as any;
|
|
271
|
+
const result = await callCommHub("send_task", {
|
|
272
|
+
alias,
|
|
273
|
+
task,
|
|
274
|
+
priority: priority || "normal",
|
|
275
|
+
from_session: ALIAS,
|
|
276
|
+
});
|
|
277
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (name === "commhub_send_message") {
|
|
281
|
+
const { alias, message } = args as any;
|
|
282
|
+
const result = await callCommHub("send_message", {
|
|
283
|
+
alias,
|
|
284
|
+
message,
|
|
285
|
+
from_session: ALIAS,
|
|
286
|
+
});
|
|
287
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (name === "commhub_get_all_status") {
|
|
291
|
+
const result = await callCommHub("get_all_status", {});
|
|
292
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "unknown tool" }) }] };
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── SSE Listener: subscribe to /events/:alias ─────
|
|
299
|
+
async function connectSSE() {
|
|
300
|
+
const url = `${COMMHUB_URL}/events/${encodeURIComponent(ALIAS)}`;
|
|
301
|
+
const headers: Record<string, string> = {};
|
|
302
|
+
if (AUTH_TOKEN) headers.Authorization = `Bearer ${AUTH_TOKEN}`;
|
|
303
|
+
|
|
304
|
+
log(`connecting to ${url}`);
|
|
305
|
+
|
|
306
|
+
while (true) {
|
|
307
|
+
try {
|
|
308
|
+
const res = await fetch(url, { headers });
|
|
309
|
+
if (!res.ok) {
|
|
310
|
+
log(`SSE error: ${res.status} ${res.statusText}`);
|
|
311
|
+
await sleep(5000);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const reader = res.body!.getReader();
|
|
316
|
+
const decoder = new TextDecoder();
|
|
317
|
+
let buffer = "";
|
|
318
|
+
|
|
319
|
+
while (true) {
|
|
320
|
+
const { done, value } = await reader.read();
|
|
321
|
+
if (done) break;
|
|
322
|
+
|
|
323
|
+
buffer += decoder.decode(value, { stream: true });
|
|
324
|
+
const lines = buffer.split("\n\n");
|
|
325
|
+
buffer = lines.pop() || "";
|
|
326
|
+
|
|
327
|
+
for (const block of lines) {
|
|
328
|
+
const dataLine = block.split("\n").find((l) => l.startsWith("data: "));
|
|
329
|
+
if (!dataLine) continue;
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const event = JSON.parse(dataLine.slice(6));
|
|
333
|
+
await handleSSEEvent(event);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
log(`parse error: ${e}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
log("SSE stream ended, reconnecting...");
|
|
341
|
+
} catch (err) {
|
|
342
|
+
log(`SSE connection error: ${err}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await sleep(3000);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function handleSSEEvent(event: any) {
|
|
350
|
+
if (event.type === "connected") {
|
|
351
|
+
log(`SSE connected as "${ALIAS}"`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (event.type === "new_message") {
|
|
356
|
+
log(`← message from ${event.from}: ${(event.message as string).slice(0, 60)}`);
|
|
357
|
+
|
|
358
|
+
await mcp.notification({
|
|
359
|
+
method: "notifications/claude/channel",
|
|
360
|
+
params: {
|
|
361
|
+
content: event.message,
|
|
362
|
+
meta: {
|
|
363
|
+
sender: event.from || "hub",
|
|
364
|
+
sender_id: "commhub",
|
|
365
|
+
user: event.from || "hub", // Claude Code 用 meta.user 显示 "commhub · {user}"
|
|
366
|
+
priority: "normal",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Auto-ack the message in inbox
|
|
372
|
+
if (event.message_id) {
|
|
373
|
+
await callCommHub("ack_inbox", { alias: ALIAS, message_id: event.message_id });
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (event.type === "new_task" || event.type === "broadcast") {
|
|
379
|
+
log(`← ${event.type}: inbox_count=${event.inbox_count} priority=${event.priority || "normal"}`);
|
|
380
|
+
|
|
381
|
+
const inbox = await callCommHub("get_inbox", {
|
|
382
|
+
alias: ALIAS,
|
|
383
|
+
limit: 5,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
if (inbox?.ok && inbox.messages?.length > 0) {
|
|
387
|
+
for (const msg of inbox.messages) {
|
|
388
|
+
const meta: Record<string, string> = {
|
|
389
|
+
sender: msg.from_session || "hub",
|
|
390
|
+
sender_id: "commhub",
|
|
391
|
+
user: msg.from_session || "hub", // Claude Code 用 meta.user 显示 "commhub · {user}"
|
|
392
|
+
task_id: msg.id,
|
|
393
|
+
priority: msg.priority || "normal",
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
await mcp.notification({
|
|
397
|
+
method: "notifications/claude/channel",
|
|
398
|
+
params: {
|
|
399
|
+
content: msg.content,
|
|
400
|
+
meta,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
log(`→ injected task ${msg.id.slice(0, 8)} from ${msg.from_session}: ${(msg.content as string).slice(0, 60)}`);
|
|
405
|
+
|
|
406
|
+
await callCommHub("ack_inbox", {
|
|
407
|
+
alias: ALIAS,
|
|
408
|
+
message_id: msg.id,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Main ────────────────────────────────────────────
|
|
416
|
+
async function main() {
|
|
417
|
+
const transport = new StdioServerTransport();
|
|
418
|
+
await mcp.connect(transport);
|
|
419
|
+
log("MCP stdio connected");
|
|
420
|
+
|
|
421
|
+
log("starting SSE listener...");
|
|
422
|
+
connectSSE().catch((err) => log(`SSE fatal: ${err}`));
|
|
423
|
+
|
|
424
|
+
callCommHub("report_status", {
|
|
425
|
+
resume_id: RESUME_ID,
|
|
426
|
+
alias: ALIAS,
|
|
427
|
+
status: "idle",
|
|
428
|
+
server: hostname(),
|
|
429
|
+
hostname: hostname(),
|
|
430
|
+
agent: "claude-code",
|
|
431
|
+
project_dir: process.cwd(),
|
|
432
|
+
tmux_name: TMUX_NAME || undefined,
|
|
433
|
+
})
|
|
434
|
+
.then(() => log(`registered as "${ALIAS}" (${RESUME_ID.slice(0, 8)})`))
|
|
435
|
+
.catch((e) => log(`warning: could not register: ${e}`));
|
|
436
|
+
|
|
437
|
+
// Heartbeat: report_status every 3 minutes to prevent offline timeout
|
|
438
|
+
setInterval(() => {
|
|
439
|
+
callCommHub("report_status", {
|
|
440
|
+
resume_id: RESUME_ID,
|
|
441
|
+
alias: ALIAS,
|
|
442
|
+
status: "idle",
|
|
443
|
+
server: hostname(),
|
|
444
|
+
hostname: hostname(),
|
|
445
|
+
agent: "claude-code",
|
|
446
|
+
project_dir: process.cwd(),
|
|
447
|
+
tmux_name: TMUX_NAME || undefined,
|
|
448
|
+
}).catch((e) => log(`heartbeat failed: ${e}`));
|
|
449
|
+
}, 3 * 60 * 1000);
|
|
450
|
+
|
|
451
|
+
log("ready — waiting for events");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
main().catch((err) => {
|
|
455
|
+
log(`fatal: ${err}`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
async function gracefulShutdown() {
|
|
460
|
+
log("shutting down, reporting offline...");
|
|
461
|
+
await callCommHub("report_status", {
|
|
462
|
+
resume_id: RESUME_ID,
|
|
463
|
+
alias: ALIAS,
|
|
464
|
+
status: "error",
|
|
465
|
+
task: "session disconnected",
|
|
466
|
+
}).catch(() => {});
|
|
467
|
+
process.exit(0);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
process.stdin.on("end", () => gracefulShutdown());
|
|
471
|
+
process.on("SIGTERM", () => gracefulShutdown());
|
|
472
|
+
process.on("SIGINT", () => gracefulShutdown());
|