cross-agent-teams-mcp 0.2.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/LICENSE +21 -0
- package/README.md +296 -0
- package/README.zh-CN.md +306 -0
- package/dist/channel-cli.d.ts +18 -0
- package/dist/channel-cli.js +358 -0
- package/dist/channel-cli.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +4585 -0
- package/dist/cli.js.map +1 -0
- package/package.json +62 -0
- package/src/channel/auto-daemon.ts +130 -0
- package/src/channel/daemon-client.ts +155 -0
- package/src/channel/proxy.ts +28 -0
- package/src/channel-cli.ts +122 -0
- package/src/cli.ts +136 -0
- package/src/daemon/auth.ts +17 -0
- package/src/daemon/channel-wake-fanout.ts +39 -0
- package/src/daemon/channel-wake-send.ts +38 -0
- package/src/daemon/cleanup.ts +38 -0
- package/src/daemon/errors.ts +18 -0
- package/src/daemon/pid.ts +33 -0
- package/src/daemon/port.ts +16 -0
- package/src/daemon/runtime-identity.ts +238 -0
- package/src/daemon/server.ts +64 -0
- package/src/daemon/shutdown.ts +12 -0
- package/src/daemon/sse-fanout.ts +96 -0
- package/src/daemon/tmux-cli.ts +61 -0
- package/src/daemon/tmux-pane-detect.ts +276 -0
- package/src/lib/client-kind.ts +1 -0
- package/src/lib/default-team.ts +18 -0
- package/src/lib/delivery-spec.ts +172 -0
- package/src/lib/schema-diff.ts +79 -0
- package/src/mcp/agent-public-row.ts +52 -0
- package/src/mcp/auto-bind-channel.ts +106 -0
- package/src/mcp/auto-bind-codex-pane.ts +170 -0
- package/src/mcp/auto-poke-fanout.ts +129 -0
- package/src/mcp/bind-channel.ts +39 -0
- package/src/mcp/bind-runtime-identity.ts +43 -0
- package/src/mcp/broadcast-to-role.ts +127 -0
- package/src/mcp/broadcast.ts +115 -0
- package/src/mcp/codex-appserver-dispatch.ts +169 -0
- package/src/mcp/codex-appserver-rpc.ts +227 -0
- package/src/mcp/codex-pane-pre-register-repo.ts +57 -0
- package/src/mcp/delivery-status.ts +114 -0
- package/src/mcp/diff-contracts.ts +25 -0
- package/src/mcp/echo.ts +8 -0
- package/src/mcp/fanout-with-retry.ts +56 -0
- package/src/mcp/get-contract.ts +24 -0
- package/src/mcp/get-inbox.ts +57 -0
- package/src/mcp/identity.ts +8 -0
- package/src/mcp/pending-contract-events.ts +36 -0
- package/src/mcp/poke-guard.ts +32 -0
- package/src/mcp/poke-retry.ts +159 -0
- package/src/mcp/poke.ts +190 -0
- package/src/mcp/pre-register-codex-pane.ts +65 -0
- package/src/mcp/register-agent.ts +84 -0
- package/src/mcp/register-codex-self.ts +276 -0
- package/src/mcp/register-contract.ts +60 -0
- package/src/mcp/send-message.ts +159 -0
- package/src/mcp/subscribe-channel-wake.ts +31 -0
- package/src/mcp/subscribe-contract.ts +24 -0
- package/src/mcp/task-add.ts +37 -0
- package/src/mcp/task-claim.ts +54 -0
- package/src/mcp/task-complete.ts +36 -0
- package/src/mcp/task-list.ts +33 -0
- package/src/mcp/tools.ts +1240 -0
- package/src/mcp/transport-dispatch.ts +171 -0
- package/src/mcp/transport.ts +204 -0
- package/src/mcp/unregister-self.ts +46 -0
- package/src/storage/agents-repo.ts +328 -0
- package/src/storage/db.ts +13 -0
- package/src/storage/events-outbox.ts +44 -0
- package/src/storage/schema.ts +180 -0
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cross-agent-teams-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP daemon for cross-agent collaboration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cross-agent-teams-mcp": "./dist/cli.js",
|
|
8
|
+
"cross-agent-teams-channel": "./dist/channel-cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"README.zh-CN.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/jtianling/cross-agent-teams-mcp.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/jtianling/cross-agent-teams-mcp#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/jtianling/cross-agent-teams-mcp/issues"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"mcp",
|
|
30
|
+
"model-context-protocol",
|
|
31
|
+
"claude-code",
|
|
32
|
+
"codex",
|
|
33
|
+
"agent",
|
|
34
|
+
"multi-agent",
|
|
35
|
+
"cross-agent"
|
|
36
|
+
],
|
|
37
|
+
"author": "jtianling <jtianling@gmail.com>",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
44
|
+
"better-sqlite3": "^11.3.0",
|
|
45
|
+
"fastify": "^5.0.0",
|
|
46
|
+
"json-schema-diff-validator": "^0.4.2",
|
|
47
|
+
"zod": "^3.23.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"tsup": "^8.3.0",
|
|
53
|
+
"tsx": "^4.21.0",
|
|
54
|
+
"typescript": "^5.6.0",
|
|
55
|
+
"vitest": "^2.1.0"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsup",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"typecheck": "tsc --noEmit"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RegistrationSequenceResult {
|
|
23
|
+
order: string[]
|
|
24
|
+
lastSubscribeResult: unknown
|
|
25
|
+
client: Client
|
|
26
|
+
transport: StreamableHTTPClientTransport
|
|
27
|
+
close: () => Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ToolResult = Record<string, unknown>
|
|
31
|
+
|
|
32
|
+
async function parseToolResult(resp: unknown): Promise<ToolResult> {
|
|
33
|
+
const r = resp as { content?: Array<{ text?: string }> }
|
|
34
|
+
const text = r.content?.[0]?.text
|
|
35
|
+
if (typeof text !== 'string') return {}
|
|
36
|
+
try { return JSON.parse(text) as ToolResult } catch { return {} }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runRegistrationSequence(
|
|
40
|
+
config: RegistrationConfig
|
|
41
|
+
): Promise<RegistrationSequenceResult> {
|
|
42
|
+
const order: string[] = []
|
|
43
|
+
const transport = new StreamableHTTPClientTransport(new URL(config.daemonUrl))
|
|
44
|
+
const client = new Client({ name: 'cross-agent-teams-channel', version: '0.1.0' })
|
|
45
|
+
|
|
46
|
+
if (config.notificationHandler) {
|
|
47
|
+
client.fallbackNotificationHandler = async (n) => {
|
|
48
|
+
if (n.method === 'notifications/channel_wake') {
|
|
49
|
+
config.notificationHandler!(n.params)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await client.connect(transport)
|
|
55
|
+
|
|
56
|
+
// 1. register_agent as proxy — identity keyed on pid, stable across reconnects
|
|
57
|
+
// so the (team, name) ON CONFLICT upsert reuses the same row instead of spamming new rows
|
|
58
|
+
const registerResp = await client.callTool({
|
|
59
|
+
name: 'register_agent',
|
|
60
|
+
arguments: {
|
|
61
|
+
client: 'custom',
|
|
62
|
+
client_name: 'cross-agent-teams-channel',
|
|
63
|
+
model: 'proxy',
|
|
64
|
+
role: '__channel_proxy__',
|
|
65
|
+
name: `channel-proxy-${process.pid}`,
|
|
66
|
+
team: 'default',
|
|
67
|
+
claude_ui_pid: process.ppid,
|
|
68
|
+
delivery: {
|
|
69
|
+
kind: 'claude-channel',
|
|
70
|
+
channel_session_id: config.channel_session_id,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
order.push('register_agent')
|
|
75
|
+
const regResult = await parseToolResult(registerResp)
|
|
76
|
+
if (!('agent_id' in regResult)) {
|
|
77
|
+
throw new Error(`register_agent failed: ${JSON.stringify(regResult)}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. subscribe_channel_wake — proxy's csid is fresh per startup
|
|
81
|
+
const subResp = await client.callTool({
|
|
82
|
+
name: 'subscribe_channel_wake',
|
|
83
|
+
arguments: { channel_session_id: config.channel_session_id }
|
|
84
|
+
})
|
|
85
|
+
order.push('subscribe_channel_wake')
|
|
86
|
+
const subResult = await parseToolResult(subResp)
|
|
87
|
+
if (!('ok' in subResult) || subResult.ok !== true) {
|
|
88
|
+
throw new Error(`subscribe_channel_wake failed: ${JSON.stringify(subResult)}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
order,
|
|
93
|
+
lastSubscribeResult: subResult,
|
|
94
|
+
client,
|
|
95
|
+
transport,
|
|
96
|
+
close: async () => {
|
|
97
|
+
try { await client.close() } catch { /* best-effort */ }
|
|
98
|
+
try { await transport.close() } catch { /* best-effort */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function runReconnectingProxy(config: ReconnectingProxyConfig): ReconnectingProxyController {
|
|
104
|
+
let stopped = false
|
|
105
|
+
let currentSeq: RegistrationSequenceResult | null = null
|
|
106
|
+
|
|
107
|
+
async function waitForDisconnect(seq: RegistrationSequenceResult): Promise<void> {
|
|
108
|
+
const interval = config.healthCheckIntervalMs ?? 200
|
|
109
|
+
let disconnected = false
|
|
110
|
+
const closeHandler = () => { disconnected = true }
|
|
111
|
+
const prevOnClose = seq.transport.onclose
|
|
112
|
+
seq.transport.onclose = () => { prevOnClose?.(); closeHandler() }
|
|
113
|
+
while (!disconnected && !stopped) {
|
|
114
|
+
await new Promise(r => setTimeout(r, interval))
|
|
115
|
+
if (disconnected || stopped) break
|
|
116
|
+
try {
|
|
117
|
+
await seq.client.callTool({ name: 'echo', arguments: { msg: 'hb' } })
|
|
118
|
+
} catch {
|
|
119
|
+
disconnected = true
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function loop(): Promise<void> {
|
|
126
|
+
while (!stopped) {
|
|
127
|
+
try {
|
|
128
|
+
const seq = await runRegistrationSequence(config)
|
|
129
|
+
currentSeq = seq
|
|
130
|
+
if (config.onSequenceComplete) config.onSequenceComplete([...seq.order])
|
|
131
|
+
|
|
132
|
+
await waitForDisconnect(seq)
|
|
133
|
+
if (config.onDisconnect) config.onDisconnect()
|
|
134
|
+
try { await seq.close() } catch { /* best-effort */ }
|
|
135
|
+
currentSeq = null
|
|
136
|
+
} catch {
|
|
137
|
+
// register/subscribe failed — wait and retry.
|
|
138
|
+
}
|
|
139
|
+
if (stopped) break
|
|
140
|
+
const wait = config.backoffInitialMs ?? 500
|
|
141
|
+
await new Promise(r => setTimeout(r, wait))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
void loop()
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
stop: async () => {
|
|
149
|
+
stopped = true
|
|
150
|
+
if (currentSeq) {
|
|
151
|
+
try { await currentSeq.close() } catch { /* best-effort */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
|
|
3
|
+
export function createProxyServer(): McpServer {
|
|
4
|
+
return new McpServer(
|
|
5
|
+
{ name: 'cross-agent-teams-channel', version: '0.1.0' },
|
|
6
|
+
{ capabilities: { experimental: { 'claude/channel': {} } } }
|
|
7
|
+
)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ChannelWakeParams {
|
|
11
|
+
content: string
|
|
12
|
+
meta: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function relayChannelWake(server: McpServer, params: ChannelWakeParams): void {
|
|
16
|
+
try {
|
|
17
|
+
const notif = {
|
|
18
|
+
method: 'notifications/claude/channel',
|
|
19
|
+
params: params as unknown as Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
const p = (server.server.notification as (n: typeof notif) => Promise<void>)(notif)
|
|
22
|
+
if (p && typeof p.catch === 'function') {
|
|
23
|
+
p.catch(() => { /* host closed — drop silently */ })
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// host transport closed or not yet connected — drop silently
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
+
import { createProxyServer, relayChannelWake } from './channel/proxy.js'
|
|
5
|
+
import { runReconnectingProxy } from './channel/daemon-client.js'
|
|
6
|
+
import { ensureDaemonHealthy } from './channel/auto-daemon.js'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_DAEMON_URL = 'http://127.0.0.1:9100/mcp'
|
|
9
|
+
|
|
10
|
+
interface CliArgs {
|
|
11
|
+
daemonUrl: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class CliArgError extends Error {
|
|
15
|
+
constructor(message: string) {
|
|
16
|
+
super(message)
|
|
17
|
+
this.name = 'CliArgError'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildStartupHint(csid: string): { content: string; meta: { source: string; kind: string } } {
|
|
22
|
+
const content = [
|
|
23
|
+
`cross-agent-teams-mcp: your channel_session_id is ${csid}.`,
|
|
24
|
+
`Preferred in Claude Code: call register_claude_self({name: "<agent-name>", ui_pid: $PPID}) from this session — do NOT pass channel_session_id here; the daemon auto-binds via ui_pid.`,
|
|
25
|
+
`Unified equivalent: register_agent({client: "claude-code", name: "<agent-name>", model: "<model>", ui_pid: $PPID}) — also without channel_session_id.`,
|
|
26
|
+
`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.`,
|
|
27
|
+
`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.`
|
|
28
|
+
].join(' ')
|
|
29
|
+
return {
|
|
30
|
+
content,
|
|
31
|
+
meta: { source: 'cross_agent_teams_mcp', kind: 'startup_bind_hint' }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseCliArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): CliArgs {
|
|
36
|
+
let daemonUrl: string | undefined
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const flag = argv[i]
|
|
40
|
+
const next = argv[i + 1]
|
|
41
|
+
switch (flag) {
|
|
42
|
+
case '--daemon-url':
|
|
43
|
+
daemonUrl = next; i++; break
|
|
44
|
+
default:
|
|
45
|
+
// Ignore unknown flags for forward-compat (including legacy
|
|
46
|
+
// --agent-team / --agent-name, which are no longer honored).
|
|
47
|
+
break
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!daemonUrl || daemonUrl.length === 0) {
|
|
52
|
+
daemonUrl = env.CROSS_AGENT_TEAMS_MCP_DAEMON_URL
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!daemonUrl || daemonUrl.length === 0) {
|
|
56
|
+
daemonUrl = DEFAULT_DAEMON_URL
|
|
57
|
+
}
|
|
58
|
+
return { daemonUrl }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function main(
|
|
62
|
+
argv: readonly string[] = process.argv.slice(2),
|
|
63
|
+
env: NodeJS.ProcessEnv = process.env
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
let args: CliArgs
|
|
66
|
+
try {
|
|
67
|
+
args = parseCliArgs(argv, env)
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
70
|
+
process.stderr.write(`cross-agent-teams-channel: ${msg}\n`)
|
|
71
|
+
process.exit(2)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fresh csid per startup — no persistence. Multi-instance safe.
|
|
75
|
+
const csid = randomUUID()
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await ensureDaemonHealthy({ daemonUrl: args.daemonUrl, log: process.stderr })
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
81
|
+
process.stderr.write(`cross-agent-teams-channel: ${msg}\n`)
|
|
82
|
+
process.exit(2)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hostServer = createProxyServer()
|
|
86
|
+
const stdioTransport = new StdioServerTransport()
|
|
87
|
+
|
|
88
|
+
const controller = runReconnectingProxy({
|
|
89
|
+
daemonUrl: args.daemonUrl,
|
|
90
|
+
channel_session_id: csid,
|
|
91
|
+
notificationHandler: (params) => {
|
|
92
|
+
relayChannelWake(hostServer, params as { content: string; meta: Record<string, string> })
|
|
93
|
+
},
|
|
94
|
+
onSequenceComplete: () => {
|
|
95
|
+
// Announce csid to Claude via host-facing channel notification so Claude
|
|
96
|
+
// can call bind_channel({channel_session_id}) to bind its own agent row.
|
|
97
|
+
const hint = buildStartupHint(csid)
|
|
98
|
+
relayChannelWake(hostServer, hint)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
let stopped = false
|
|
103
|
+
const shutdown = async (): Promise<void> => {
|
|
104
|
+
if (stopped) return
|
|
105
|
+
stopped = true
|
|
106
|
+
try { await controller.stop() } catch { /* best-effort */ }
|
|
107
|
+
try { await hostServer.close() } catch { /* best-effort */ }
|
|
108
|
+
process.exit(0)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
stdioTransport.onclose = () => { void shutdown() }
|
|
112
|
+
|
|
113
|
+
await hostServer.connect(stdioTransport)
|
|
114
|
+
|
|
115
|
+
process.on('SIGTERM', () => { void shutdown() })
|
|
116
|
+
process.on('SIGINT', () => { void shutdown() })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
120
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
121
|
+
void main()
|
|
122
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { startServer } from './daemon/server.js'
|
|
6
|
+
import { wireShutdown } from './daemon/shutdown.js'
|
|
7
|
+
import { acquirePidFile } from './daemon/pid.js'
|
|
8
|
+
import { selectPort } from './daemon/port.js'
|
|
9
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
10
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
11
|
+
|
|
12
|
+
function parseArg(name: string, def?: string): string | undefined {
|
|
13
|
+
const i = process.argv.indexOf(name)
|
|
14
|
+
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function defaultHome(): string {
|
|
18
|
+
return process.env.CROSS_AGENT_TEAMS_MCP_HOME ?? join(homedir(), '.cross-agent-teams-mcp')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function runDaemon(): Promise<void> {
|
|
22
|
+
const home = defaultHome()
|
|
23
|
+
const pidPath = parseArg('--pid-file', join(home, 'daemon.pid'))!
|
|
24
|
+
const dbPath = parseArg('--db', join(home, 'data.db'))!
|
|
25
|
+
const token = parseArg('--token')
|
|
26
|
+
const requested = Number(parseArg('--port', '9100'))
|
|
27
|
+
const port = requested === 0 ? 0 : await selectPort([requested, requested + 1, requested + 2])
|
|
28
|
+
const r = acquirePidFile(pidPath, port || requested)
|
|
29
|
+
if (!r.ok) { console.error('daemon already running pid=' + r.pid); process.exit(1) }
|
|
30
|
+
const started = await startServer({ dbPath, token, port })
|
|
31
|
+
wireShutdown(started.app, pidPath)
|
|
32
|
+
console.log(`listening on ${started.host}:${started.port}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveDaemonPort(explicit: string | undefined): number | undefined {
|
|
36
|
+
if (explicit !== undefined) {
|
|
37
|
+
const n = Number(explicit)
|
|
38
|
+
if (Number.isInteger(n) && n > 0) return n
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
const pidPath = parseArg('--pid-file', join(defaultHome(), 'daemon.pid'))!
|
|
42
|
+
if (!existsSync(pidPath)) return undefined
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(readFileSync(pidPath, 'utf8')) as { port?: number }
|
|
45
|
+
if (typeof parsed.port === 'number' && parsed.port > 0) return parsed.port
|
|
46
|
+
} catch { /* ignore corrupt pid file */ }
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function runPreRegisterCodexPane(): Promise<void> {
|
|
51
|
+
const pane = parseArg('--pane')
|
|
52
|
+
const agentId = parseArg('--agent-id')
|
|
53
|
+
const ttlRaw = parseArg('--ttl')
|
|
54
|
+
const tokenExplicit = parseArg('--token')
|
|
55
|
+
const portExplicit = parseArg('--port')
|
|
56
|
+
|
|
57
|
+
if (!pane || !agentId) {
|
|
58
|
+
console.error('usage: cross-agent-teams-mcp pre-register-codex-pane --pane <pane_id> --agent-id <uuid> [--ttl <seconds>] [--port <n>] [--token <t>]')
|
|
59
|
+
process.exit(2)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const port = resolveDaemonPort(portExplicit)
|
|
63
|
+
if (!port) {
|
|
64
|
+
console.error('{"ok":false,"error":"daemon_port_unresolved","detail":"pass --port or start the daemon so the pid file is present"}')
|
|
65
|
+
process.exit(1)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const token = tokenExplicit ?? process.env.CROSS_AGENT_TEAMS_MCP_TOKEN
|
|
69
|
+
const host = process.env.CROSS_AGENT_TEAMS_MCP_HOST ?? '127.0.0.1'
|
|
70
|
+
const base = new URL(`http://${host}:${port}/mcp`)
|
|
71
|
+
|
|
72
|
+
const requestInit: RequestInit | undefined = token
|
|
73
|
+
? { headers: { Authorization: `Bearer ${token}` } }
|
|
74
|
+
: undefined
|
|
75
|
+
|
|
76
|
+
const transport = new StreamableHTTPClientTransport(base, {
|
|
77
|
+
requestInit,
|
|
78
|
+
})
|
|
79
|
+
const client = new Client({ name: 'cross-agent-teams-mcp-cli', version: '0.1.0' })
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await client.connect(transport)
|
|
83
|
+
const args: Record<string, unknown> = {
|
|
84
|
+
pane_id: pane,
|
|
85
|
+
xats_agent_id: agentId,
|
|
86
|
+
}
|
|
87
|
+
if (ttlRaw !== undefined) {
|
|
88
|
+
const ttl = Number(ttlRaw)
|
|
89
|
+
if (!Number.isInteger(ttl) || ttl <= 0) {
|
|
90
|
+
console.error('{"ok":false,"error":"invalid_ttl"}')
|
|
91
|
+
process.exit(2)
|
|
92
|
+
}
|
|
93
|
+
args.ttl_seconds = ttl
|
|
94
|
+
}
|
|
95
|
+
const resp = await client.callTool({
|
|
96
|
+
name: 'pre_register_codex_pane',
|
|
97
|
+
arguments: args,
|
|
98
|
+
})
|
|
99
|
+
const content = (resp as { content?: Array<{ text?: string }> }).content
|
|
100
|
+
const text = content?.[0]?.text ?? ''
|
|
101
|
+
let parsed: unknown
|
|
102
|
+
try { parsed = JSON.parse(text) } catch { parsed = { raw: text } }
|
|
103
|
+
const obj = (parsed ?? {}) as Record<string, unknown>
|
|
104
|
+
if (obj.ok === true) {
|
|
105
|
+
console.log(JSON.stringify(obj))
|
|
106
|
+
process.exit(0)
|
|
107
|
+
}
|
|
108
|
+
console.error(JSON.stringify(obj))
|
|
109
|
+
process.exit(1)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
112
|
+
console.error(JSON.stringify({ ok: false, error: 'cli_failed', detail: msg }))
|
|
113
|
+
process.exit(1)
|
|
114
|
+
} finally {
|
|
115
|
+
try { await transport.close() } catch { /* best-effort */ }
|
|
116
|
+
try { await client.close() } catch { /* best-effort */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function main(): Promise<void> {
|
|
121
|
+
const cmd = process.argv[2]
|
|
122
|
+
if (cmd === 'daemon') {
|
|
123
|
+
await runDaemon()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (cmd === 'pre-register-codex-pane') {
|
|
127
|
+
await runPreRegisterCodexPane()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
console.error('usage: cross-agent-teams-mcp <daemon|pre-register-codex-pane> [options]')
|
|
131
|
+
process.exit(2)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
main().catch((e) => { console.error(e?.message ?? e); process.exit(1) })
|
|
135
|
+
|
|
136
|
+
export {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from 'fastify'
|
|
2
|
+
|
|
3
|
+
export function extractToken(req: FastifyRequest): string | undefined {
|
|
4
|
+
const h = req.headers['authorization']
|
|
5
|
+
if (typeof h === 'string' && h.startsWith('Bearer ')) return h.slice(7)
|
|
6
|
+
const q = (req.query as Record<string, unknown> | undefined)?.token
|
|
7
|
+
return typeof q === 'string' ? q : undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function makeAuthHook(expected: string | undefined) {
|
|
11
|
+
return async (req: FastifyRequest, reply: FastifyReply) => {
|
|
12
|
+
if (req.url.startsWith('/health')) return
|
|
13
|
+
if (!expected) return
|
|
14
|
+
const got = extractToken(req)
|
|
15
|
+
if (got !== expected) return reply.code(401).send({ error: 'invalid_token' })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type ChannelWakeSink = (payload: unknown) => void
|
|
2
|
+
|
|
3
|
+
interface Entry {
|
|
4
|
+
sessionId: string
|
|
5
|
+
sink: ChannelWakeSink
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class ChannelWakeFanout {
|
|
9
|
+
private readonly entries = new Map<string, Entry>()
|
|
10
|
+
|
|
11
|
+
attach(channel_session_id: string, sink: ChannelWakeSink, sessionId: string): void {
|
|
12
|
+
this.entries.set(channel_session_id, { sessionId, sink })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
detach(channel_session_id: string): void {
|
|
16
|
+
this.entries.delete(channel_session_id)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
detachBySession(sessionId: string): void {
|
|
20
|
+
for (const [csid, entry] of this.entries) {
|
|
21
|
+
if (entry.sessionId === sessionId) this.entries.delete(csid)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
send(channel_session_id: string, payload: unknown): boolean {
|
|
26
|
+
const entry = this.entries.get(channel_session_id)
|
|
27
|
+
if (!entry) return false
|
|
28
|
+
try {
|
|
29
|
+
entry.sink(payload)
|
|
30
|
+
} catch {
|
|
31
|
+
// sink failure is the caller's concern; swallow to preserve map state
|
|
32
|
+
}
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
has(channel_session_id: string): boolean {
|
|
37
|
+
return this.entries.has(channel_session_id)
|
|
38
|
+
}
|
|
39
|
+
}
|