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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/README.zh-CN.md +306 -0
  4. package/dist/channel-cli.d.ts +18 -0
  5. package/dist/channel-cli.js +358 -0
  6. package/dist/channel-cli.js.map +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +4585 -0
  9. package/dist/cli.js.map +1 -0
  10. package/package.json +62 -0
  11. package/src/channel/auto-daemon.ts +130 -0
  12. package/src/channel/daemon-client.ts +155 -0
  13. package/src/channel/proxy.ts +28 -0
  14. package/src/channel-cli.ts +122 -0
  15. package/src/cli.ts +136 -0
  16. package/src/daemon/auth.ts +17 -0
  17. package/src/daemon/channel-wake-fanout.ts +39 -0
  18. package/src/daemon/channel-wake-send.ts +38 -0
  19. package/src/daemon/cleanup.ts +38 -0
  20. package/src/daemon/errors.ts +18 -0
  21. package/src/daemon/pid.ts +33 -0
  22. package/src/daemon/port.ts +16 -0
  23. package/src/daemon/runtime-identity.ts +238 -0
  24. package/src/daemon/server.ts +64 -0
  25. package/src/daemon/shutdown.ts +12 -0
  26. package/src/daemon/sse-fanout.ts +96 -0
  27. package/src/daemon/tmux-cli.ts +61 -0
  28. package/src/daemon/tmux-pane-detect.ts +276 -0
  29. package/src/lib/client-kind.ts +1 -0
  30. package/src/lib/default-team.ts +18 -0
  31. package/src/lib/delivery-spec.ts +172 -0
  32. package/src/lib/schema-diff.ts +79 -0
  33. package/src/mcp/agent-public-row.ts +52 -0
  34. package/src/mcp/auto-bind-channel.ts +106 -0
  35. package/src/mcp/auto-bind-codex-pane.ts +170 -0
  36. package/src/mcp/auto-poke-fanout.ts +129 -0
  37. package/src/mcp/bind-channel.ts +39 -0
  38. package/src/mcp/bind-runtime-identity.ts +43 -0
  39. package/src/mcp/broadcast-to-role.ts +127 -0
  40. package/src/mcp/broadcast.ts +115 -0
  41. package/src/mcp/codex-appserver-dispatch.ts +169 -0
  42. package/src/mcp/codex-appserver-rpc.ts +227 -0
  43. package/src/mcp/codex-pane-pre-register-repo.ts +57 -0
  44. package/src/mcp/delivery-status.ts +114 -0
  45. package/src/mcp/diff-contracts.ts +25 -0
  46. package/src/mcp/echo.ts +8 -0
  47. package/src/mcp/fanout-with-retry.ts +56 -0
  48. package/src/mcp/get-contract.ts +24 -0
  49. package/src/mcp/get-inbox.ts +57 -0
  50. package/src/mcp/identity.ts +8 -0
  51. package/src/mcp/pending-contract-events.ts +36 -0
  52. package/src/mcp/poke-guard.ts +32 -0
  53. package/src/mcp/poke-retry.ts +159 -0
  54. package/src/mcp/poke.ts +190 -0
  55. package/src/mcp/pre-register-codex-pane.ts +65 -0
  56. package/src/mcp/register-agent.ts +84 -0
  57. package/src/mcp/register-codex-self.ts +276 -0
  58. package/src/mcp/register-contract.ts +60 -0
  59. package/src/mcp/send-message.ts +159 -0
  60. package/src/mcp/subscribe-channel-wake.ts +31 -0
  61. package/src/mcp/subscribe-contract.ts +24 -0
  62. package/src/mcp/task-add.ts +37 -0
  63. package/src/mcp/task-claim.ts +54 -0
  64. package/src/mcp/task-complete.ts +36 -0
  65. package/src/mcp/task-list.ts +33 -0
  66. package/src/mcp/tools.ts +1240 -0
  67. package/src/mcp/transport-dispatch.ts +171 -0
  68. package/src/mcp/transport.ts +204 -0
  69. package/src/mcp/unregister-self.ts +46 -0
  70. package/src/storage/agents-repo.ts +328 -0
  71. package/src/storage/db.ts +13 -0
  72. package/src/storage/events-outbox.ts +44 -0
  73. package/src/storage/schema.ts +180 -0
@@ -0,0 +1,171 @@
1
+ import type { ChannelWakeFanout } from '../daemon/channel-wake-fanout.js'
2
+ import { sendChannelWake } from '../daemon/channel-wake-send.js'
3
+ import type { ClientKind } from '../lib/client-kind.js'
4
+ import type { DeliverySpec } from '../lib/delivery-spec.js'
5
+ import {
6
+ dispatchCodexAppserverPoke,
7
+ type CodexAppserverDispatchResult,
8
+ } from './codex-appserver-dispatch.js'
9
+
10
+ export interface DispatchDeps {
11
+ channelWakeFanout?: ChannelWakeFanout
12
+ tmuxPoke: (args: { pane_id: string; content: string }) => Promise<TmuxPokeResult>
13
+ codexAppserverDispatch?: (args: {
14
+ delivery: Extract<DeliverySpec, { kind: 'codex-appserver' }>
15
+ content: string
16
+ }) => Promise<CodexAppserverDispatchResult>
17
+ }
18
+
19
+ export type TmuxPokeResult =
20
+ | { ok: true; pane_tail_before: string; pane_tail_after: string }
21
+ | { error: string; detail?: unknown }
22
+
23
+ export interface TargetRow {
24
+ client: ClientKind | null
25
+ delivery: DeliverySpec
26
+ tmux_pane_id: string | null
27
+ }
28
+
29
+ export interface DispatchInput {
30
+ content: string
31
+ meta: Record<string, string>
32
+ }
33
+
34
+ export type DispatchResult =
35
+ | {
36
+ ok: true
37
+ transport_used: 'claude-channel'
38
+ channel_session_id: string
39
+ }
40
+ | {
41
+ ok: true
42
+ transport_used: 'tmux-poke'
43
+ pane_id: string
44
+ pane_tail_before: string
45
+ pane_tail_after: string
46
+ }
47
+ | {
48
+ ok: true
49
+ transport_used: 'codex-appserver'
50
+ thread_id: string
51
+ }
52
+ | {
53
+ error: string
54
+ detail?: unknown
55
+ transport_used?: 'tmux-poke' | 'codex-appserver'
56
+ }
57
+
58
+ export async function dispatchPoke(
59
+ deps: DispatchDeps,
60
+ target: TargetRow,
61
+ input: DispatchInput
62
+ ): Promise<DispatchResult> {
63
+ const client = resolveClient(target)
64
+ if (client === 'claude-code') return dispatchClaude(deps, target, input)
65
+ if (client === 'codex') return dispatchCodex(deps, target, input)
66
+ return dispatchUnknown(deps, target, input)
67
+ }
68
+
69
+ function resolveClient(target: TargetRow): ClientKind | null {
70
+ if (target.client) return target.client
71
+ if (target.delivery.kind === 'claude-channel') return 'claude-code'
72
+ if (target.delivery.kind === 'codex-appserver') return 'codex'
73
+ return null
74
+ }
75
+
76
+ async function dispatchTmux(
77
+ deps: DispatchDeps,
78
+ paneId: string,
79
+ content: string
80
+ ): Promise<DispatchResult> {
81
+ const tmuxResult = await deps.tmuxPoke({ pane_id: paneId, content })
82
+ if ('ok' in tmuxResult && tmuxResult.ok) {
83
+ return {
84
+ ok: true,
85
+ transport_used: 'tmux-poke',
86
+ pane_id: paneId,
87
+ pane_tail_before: tmuxResult.pane_tail_before,
88
+ pane_tail_after: tmuxResult.pane_tail_after,
89
+ }
90
+ }
91
+ return {
92
+ ...(tmuxResult as { error: string; detail?: unknown }),
93
+ transport_used: 'tmux-poke',
94
+ }
95
+ }
96
+
97
+ async function dispatchClaude(
98
+ deps: DispatchDeps,
99
+ target: TargetRow,
100
+ input: DispatchInput
101
+ ): Promise<DispatchResult> {
102
+ const paneId = target.tmux_pane_id
103
+ const channelSubscribed =
104
+ target.delivery.kind === 'claude-channel' &&
105
+ (deps.channelWakeFanout?.has(target.delivery.channel_session_id) ?? false)
106
+
107
+ if (target.delivery.kind === 'claude-channel' && channelSubscribed && deps.channelWakeFanout) {
108
+ const result = sendChannelWake(
109
+ deps.channelWakeFanout,
110
+ target.delivery.channel_session_id,
111
+ input
112
+ )
113
+ if (result.ok) {
114
+ return {
115
+ ok: true,
116
+ transport_used: 'claude-channel',
117
+ channel_session_id: target.delivery.channel_session_id,
118
+ }
119
+ }
120
+ }
121
+
122
+ if (paneId) return dispatchTmux(deps, paneId, input.content)
123
+ return {
124
+ error: 'no_transport_available',
125
+ detail: {
126
+ channel_subscribed: channelSubscribed,
127
+ tmux_pane_set: false,
128
+ },
129
+ }
130
+ }
131
+
132
+ async function dispatchCodex(
133
+ deps: DispatchDeps,
134
+ target: TargetRow,
135
+ input: DispatchInput
136
+ ): Promise<DispatchResult> {
137
+ const paneId = target.tmux_pane_id
138
+ if (target.delivery.kind === 'codex-appserver') {
139
+ const result = await (deps.codexAppserverDispatch ?? dispatchCodexAppserverPoke)({
140
+ delivery: target.delivery,
141
+ content: input.content,
142
+ })
143
+ if ('ok' in result && result.ok) return result
144
+ if (paneId) return dispatchTmux(deps, paneId, input.content)
145
+ return result
146
+ }
147
+ if (paneId) return dispatchTmux(deps, paneId, input.content)
148
+ return {
149
+ error: 'no_transport_available',
150
+ detail: {
151
+ codex_bound: false,
152
+ tmux_pane_set: false,
153
+ },
154
+ }
155
+ }
156
+
157
+ async function dispatchUnknown(
158
+ deps: DispatchDeps,
159
+ target: TargetRow,
160
+ input: DispatchInput
161
+ ): Promise<DispatchResult> {
162
+ const paneId = target.tmux_pane_id
163
+ if (paneId) return dispatchTmux(deps, paneId, input.content)
164
+ return {
165
+ error: 'no_transport_available',
166
+ detail: {
167
+ channel_subscribed: false,
168
+ tmux_pane_set: false,
169
+ },
170
+ }
171
+ }
@@ -0,0 +1,204 @@
1
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
2
+ import type Database from 'better-sqlite3'
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
5
+ import { randomUUID, createHash } from 'node:crypto'
6
+ import { echoSchema, echoHandler } from './echo.js'
7
+ import { registerBusinessTools, type AgentIdHolder } from './tools.js'
8
+ import type { SseFanout, SseSink } from '../daemon/sse-fanout.js'
9
+ import type { ChannelWakeFanout } from '../daemon/channel-wake-fanout.js'
10
+
11
+ interface Session {
12
+ transport: StreamableHTTPServerTransport
13
+ server: McpServer
14
+ sessionId: string
15
+ agentIdHolder: AgentIdHolder
16
+ clientInfo?: {
17
+ name?: string
18
+ version?: string
19
+ }
20
+ }
21
+
22
+ export function mountMcp(
23
+ app: FastifyInstance,
24
+ db: Database.Database,
25
+ fanout: SseFanout,
26
+ channelWakeFanout?: ChannelWakeFanout
27
+ ): void {
28
+ const sessions = new Map<string, Session>()
29
+ // Once register_agent succeeds for a session id, pin the owning Authorization hash.
30
+ // A later register_agent presenting a different Authorization triggers HTTP 409.
31
+ const sessionOwners = new Map<string, string>()
32
+
33
+ function createSession(): Session {
34
+ const server = new McpServer(
35
+ { name: 'cross-agent-teams-mcp', version: '0.1.0' },
36
+ {
37
+ instructions:
38
+ 'xats 是 cross-agent-teams 的缩写. 当用户或其他 agent 提到 xats 时, 指的是本 MCP server (cross-agent-teams-mcp) 及其跨 agent 协作工具 (register_agent, send_message, task_* 等). 用户说"注册到 xats"或"注册到 cross-agent-teams"时, 表示注册到本 MCP 服务, xats 和 cross-agent-teams 不是 team 名, 不要把它们传给 team. 不要把单独的常用词"注册"默认劫持为本工具; 只有当前上下文已经在讨论 cross-agent-teams 注册时才可续接. 注册时 (register_agent / register_claude_self) 如果用户未显式指定 team, 默认传 project_dir=当前工作目录 (cwd), daemon 会用它的 basename 作为 team 默认值; 都不传时回落 default.'
39
+ + ' Codex clients: if your tool shell env has CODEX_THREAD_ID set (codex 0.124.0+ exports it), pass its value as `thread_id` and prefer `register_codex_self` over `register_agent`. Do NOT attempt to discover or pass `ui_pid` from codex — the launcher\'s `pre_register_codex_pane` pre-reg flow handles tmux pane binding automatically; supplying `ui_pid` manually disables that auto-bind path.'
40
+ }
41
+ )
42
+ const agentIdHolder: AgentIdHolder = { current: undefined }
43
+ server.registerTool('echo', { title: 'Echo', description: 'Return the input', inputSchema: echoSchema }, echoHandler as any)
44
+
45
+ let sessionIdForCaller: string | undefined
46
+ // `caller()` returns the session id before register_agent succeeds (to serve as
47
+ // a stable connection_id), and the bound agent_id after register succeeds.
48
+ const getCallerAgentId = (): string | undefined =>
49
+ agentIdHolder.current ?? sessionIdForCaller
50
+
51
+ const sink: SseSink = {
52
+ send(msg: Record<string, unknown>): void {
53
+ const payload = {
54
+ jsonrpc: '2.0' as const,
55
+ method: 'notifications/contract_event',
56
+ params: msg
57
+ }
58
+ void transport.send(payload).catch(() => { /* no active GET stream yet */ })
59
+ },
60
+ sendHeartbeat(): void {
61
+ void transport.send({
62
+ jsonrpc: '2.0' as const,
63
+ method: 'notifications/heartbeat',
64
+ params: {}
65
+ }).catch(() => { /* no active GET stream yet */ })
66
+ },
67
+ close(): void { /* transport.onclose handles lifecycle */ }
68
+ }
69
+
70
+ const onRegisterSuccess = (agent_id: string, team: string): void => {
71
+ // Detach any prior sink registered under this agent_id (e.g. from a previous
72
+ // session that reused the same identity) before attaching the new one.
73
+ try { fanout.detach(agent_id) } catch { /* ignore */ }
74
+ // If this session had previously bound a different agent_id (e.g. role change
75
+ // mid-session), detach that too.
76
+ if (agentIdHolder.current && agentIdHolder.current !== agent_id) {
77
+ try { fanout.detach(agentIdHolder.current) } catch { /* ignore */ }
78
+ }
79
+ fanout.attach(agent_id, team, sink)
80
+ agentIdHolder.current = agent_id
81
+ }
82
+
83
+ const onUnregisterSuccess = (agent_id: string): void => {
84
+ try { fanout.detach(agent_id) } catch { /* ignore */ }
85
+ if (sessionIdForCaller && channelWakeFanout) {
86
+ try { channelWakeFanout.detachBySession(sessionIdForCaller) } catch { /* ignore */ }
87
+ }
88
+ if (agentIdHolder.current === agent_id) agentIdHolder.current = undefined
89
+ }
90
+
91
+ const transport = new StreamableHTTPServerTransport({
92
+ sessionIdGenerator: () => randomUUID(),
93
+ onsessioninitialized: (sid: string) => {
94
+ sessionIdForCaller = sid
95
+ sessions.set(sid, { transport, server, sessionId: sid, agentIdHolder, clientInfo: undefined })
96
+ }
97
+ })
98
+ transport.onclose = () => {
99
+ if (agentIdHolder.current) {
100
+ try { fanout.detach(agentIdHolder.current) } catch { /* ignore */ }
101
+ }
102
+ if (transport.sessionId && channelWakeFanout) {
103
+ try { channelWakeFanout.detachBySession(transport.sessionId) } catch { /* ignore */ }
104
+ }
105
+ if (transport.sessionId) {
106
+ sessions.delete(transport.sessionId)
107
+ sessionOwners.delete(transport.sessionId)
108
+ }
109
+ }
110
+ registerBusinessTools(
111
+ server,
112
+ db,
113
+ getCallerAgentId,
114
+ fanout,
115
+ onRegisterSuccess,
116
+ () => sessionIdForCaller,
117
+ channelWakeFanout,
118
+ () => transport,
119
+ () => {
120
+ const sid = sessionIdForCaller
121
+ if (!sid) return undefined
122
+ return sessions.get(sid)?.clientInfo
123
+ },
124
+ onUnregisterSuccess
125
+ )
126
+ server.connect(transport)
127
+ return { transport, server, sessionId: '', agentIdHolder }
128
+ }
129
+
130
+ function authHashFor(req: FastifyRequest): string | null {
131
+ const raw = req.headers['authorization']
132
+ if (typeof raw !== 'string') return null
133
+ const trimmed = raw.trim()
134
+ if (trimmed.length === 0) return null
135
+ return createHash('sha256').update(trimmed).digest('hex')
136
+ }
137
+
138
+ interface ToolsCallBody {
139
+ method?: string
140
+ params?: { name?: string; arguments?: Record<string, unknown> }
141
+ }
142
+
143
+ app.post('/mcp', async (req: FastifyRequest, reply: FastifyReply) => {
144
+ const sid = req.headers['mcp-session-id'] as string | undefined
145
+ const body = req.body as ToolsCallBody | undefined
146
+ const isInit = body?.method === 'initialize'
147
+ let session = sid ? sessions.get(sid) : undefined
148
+ if (!session && !isInit) { return reply.code(400).send({ error: 'unknown_session' }) }
149
+
150
+ // register_agent presenting a different Authorization header than the one that
151
+ // first claimed this session id -> agent_id_collision (HTTP 409). Absence of
152
+ // an Authorization header disables collision enforcement per spec.
153
+ if (session && body?.method === 'tools/call' && body.params?.name === 'register_agent') {
154
+ const authHash = authHashFor(req)
155
+ if (authHash !== null) {
156
+ const owner = sessionOwners.get(session.sessionId)
157
+ if (owner && owner !== authHash) {
158
+ return reply.code(409).send({ error: 'agent_id_collision' })
159
+ }
160
+ if (!owner) sessionOwners.set(session.sessionId, authHash)
161
+ }
162
+ }
163
+
164
+ // Spoofed from_agent_id on tools/call -> 403. Compare against the session's
165
+ // currently bound agent_id (post register_agent), NOT the raw MCP session id.
166
+ if (session && body?.method === 'tools/call') {
167
+ const claimed = body.params?.arguments?.from_agent_id
168
+ if (typeof claimed === 'string') {
169
+ const current = session.agentIdHolder.current
170
+ if (current === undefined || claimed !== current) {
171
+ return reply.code(403).send({ error: 'identity_mismatch' })
172
+ }
173
+ }
174
+ }
175
+
176
+ if (!session) { session = createSession() }
177
+ if (body?.method === 'initialize') {
178
+ const params = body.params as { clientInfo?: { name?: unknown; version?: unknown } } | undefined
179
+ const clientInfo = params?.clientInfo
180
+ session.clientInfo = {
181
+ name: typeof clientInfo?.name === 'string' ? clientInfo.name : undefined,
182
+ version: typeof clientInfo?.version === 'string' ? clientInfo.version : undefined,
183
+ }
184
+ }
185
+ await session.transport.handleRequest(req.raw, reply.raw, body)
186
+ return reply
187
+ })
188
+
189
+ app.get('/mcp', async (req: FastifyRequest, reply: FastifyReply) => {
190
+ const sid = req.headers['mcp-session-id'] as string | undefined
191
+ const session = sid ? sessions.get(sid) : undefined
192
+ if (!session) return reply.code(400).send({ error: 'unknown_session' })
193
+ await session.transport.handleRequest(req.raw, reply.raw)
194
+ return reply
195
+ })
196
+
197
+ app.delete('/mcp', async (req: FastifyRequest, reply: FastifyReply) => {
198
+ const sid = req.headers['mcp-session-id'] as string | undefined
199
+ const session = sid ? sessions.get(sid) : undefined
200
+ if (!session) return reply.code(400).send({ error: 'unknown_session' })
201
+ await session.transport.handleRequest(req.raw, reply.raw)
202
+ return reply
203
+ })
204
+ }
@@ -0,0 +1,46 @@
1
+ import type Database from 'better-sqlite3'
2
+ import type { AgentsRepo } from '../storage/agents-repo.js'
3
+
4
+ export type UnregisterSelfResult =
5
+ | { ok: true; team: string; name: string; agent_id: string }
6
+ | { error: 'unknown_agent' }
7
+ | { error: 'tasks_in_progress'; task_ids: string[] }
8
+
9
+ export class UnregisterSelfService {
10
+ constructor(
11
+ private readonly db: Database.Database,
12
+ private readonly agents: AgentsRepo
13
+ ) {}
14
+
15
+ unregister(args: { caller: string }): UnregisterSelfResult {
16
+ const caller = this.agents.findById(args.caller)
17
+ if (!caller) return { error: 'unknown_agent' }
18
+
19
+ const task_ids = this.agents.listClaimedInProgressTaskIds({
20
+ agent_id: caller.agent_id,
21
+ team: caller.team,
22
+ })
23
+ if (task_ids.length > 0) {
24
+ return { error: 'tasks_in_progress', task_ids }
25
+ }
26
+
27
+ let removed = false
28
+ const tx = this.db.transaction(() => {
29
+ removed = this.agents.deleteById(caller.agent_id)
30
+ if (!removed) return
31
+ this.agents.deleteContractSubscriptions({
32
+ agent_id: caller.agent_id,
33
+ team: caller.team,
34
+ })
35
+ })
36
+ tx()
37
+
38
+ if (!removed) return { error: 'unknown_agent' }
39
+ return {
40
+ ok: true,
41
+ team: caller.team,
42
+ name: caller.name,
43
+ agent_id: caller.agent_id,
44
+ }
45
+ }
46
+ }