@modelprofile.com/mcp-crossharness 5.0.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.
@@ -0,0 +1,309 @@
1
+ import * as plugins from './plugins.js';
2
+ import { OpencodeConnection } from './classes.connection.opencode.js';
3
+ import { CodexConnection } from './classes.connection.codex.js';
4
+ import type * as interfaces from './interfaces.js';
5
+
6
+ export interface IConnectionRegistryOptions {
7
+ allowedServerUrls?: string[];
8
+ maxConnections?: number;
9
+ openCodeCredentials?: IOpenCodeCredentials;
10
+ }
11
+
12
+ export interface IOpenCodeCredentials {
13
+ serverUrl: string;
14
+ username?: string;
15
+ password: string;
16
+ }
17
+
18
+ export class ConnectionRegistry {
19
+ private connections = new Map<string, interfaces.IHarnessConnection>();
20
+ private connecting = new Map<string, interfaces.IHarnessConnection>();
21
+ private closingConnections = new Map<string, interfaces.IHarnessConnection>();
22
+ private allowedServerUrls: Set<string>;
23
+ private maxConnections: number;
24
+ private openCodeCredentials: IOpenCodeCredentials | undefined;
25
+ private closing = false;
26
+ private closingTasks = new Map<string, Promise<void>>();
27
+ private shutdownPromise: Promise<void> | undefined;
28
+
29
+ constructor(optionsArg: IConnectionRegistryOptions = {}) {
30
+ const configured =
31
+ optionsArg.allowedServerUrls ??
32
+ (process.env.CROSSHARNESS_ALLOWED_SERVER_URLS ?? '')
33
+ .split(',')
34
+ .map((entry) => entry.trim())
35
+ .filter(Boolean);
36
+ this.allowedServerUrls = new Set(configured.map((entry) => this.normalizeUrl(entry).toString()));
37
+ this.maxConnections = optionsArg.maxConnections ?? 32;
38
+ if (!Number.isInteger(this.maxConnections) || this.maxConnections < 1) {
39
+ throw new Error('maxConnections must be a positive integer');
40
+ }
41
+ if (optionsArg.openCodeCredentials) {
42
+ const { serverUrl, username, password } = optionsArg.openCodeCredentials;
43
+ const credentialUrl = this.normalizeUrl(serverUrl);
44
+ if (
45
+ serverUrl.length > 2048
46
+ || serverUrl.includes('?')
47
+ || serverUrl.includes('#')
48
+ || credentialUrl.username
49
+ || credentialUrl.password
50
+ || credentialUrl.search
51
+ || credentialUrl.hash
52
+ || credentialUrl.pathname !== '/'
53
+ || !['http:', 'https:'].includes(credentialUrl.protocol)
54
+ || !this.allowedServerUrls.has(credentialUrl.toString())
55
+ || (
56
+ credentialUrl.protocol === 'http:'
57
+ && !['127.0.0.1', '[::1]', '::1', 'localhost'].includes(credentialUrl.hostname)
58
+ )
59
+ || typeof password !== 'string'
60
+ || password.length === 0
61
+ || Buffer.byteLength(password, 'utf8') > 1024
62
+ || password.includes('\r')
63
+ || password.includes('\n')
64
+ || (username !== undefined && (
65
+ typeof username !== 'string'
66
+ || username.length === 0
67
+ || Buffer.byteLength(username, 'utf8') > 1024
68
+ || username.includes('\r')
69
+ || username.includes('\n')
70
+ ))
71
+ ) throw new Error('openCodeCredentials are invalid');
72
+ this.openCodeCredentials = {
73
+ serverUrl: credentialUrl.toString(),
74
+ password,
75
+ ...(username === undefined ? {} : { username }),
76
+ };
77
+ }
78
+ }
79
+
80
+ public async connect(
81
+ harnessArg: interfaces.THarnessName,
82
+ serverUrlArg: string,
83
+ directoryArg: string,
84
+ signalArg?: AbortSignal,
85
+ ): Promise<interfaces.IConnectionInfo> {
86
+ if (signalArg?.aborted) throw this.abortError(signalArg.reason);
87
+ if (this.closing) {
88
+ throw new Error('connection registry is closing');
89
+ }
90
+ if (harnessArg === 'claudecode') {
91
+ throw new Error(
92
+ 'Claude Code 2.1.219 exposes no URL-addressable session server; connection-first Crossharness cannot connect to it',
93
+ );
94
+ }
95
+ if (!directoryArg.startsWith('/')) {
96
+ throw new Error('connection directory must be an absolute path on the harness server');
97
+ }
98
+ if (directoryArg.length > 4096) {
99
+ throw new Error('connection directory exceeds 4096 characters');
100
+ }
101
+ if (serverUrlArg.length > 2048) {
102
+ throw new Error('server URL exceeds 2048 characters');
103
+ }
104
+ const url = this.validateAllowedUrl(harnessArg, serverUrlArg);
105
+ if (
106
+ this.connections.size + this.connecting.size + this.closingConnections.size >=
107
+ this.maxConnections
108
+ ) {
109
+ throw new Error(`connection registry is full (maximum ${this.maxConnections})`);
110
+ }
111
+ const duplicate = [
112
+ ...this.connections.values(),
113
+ ...this.connecting.values(),
114
+ ...this.closingConnections.values(),
115
+ ].find(
116
+ (connection) =>
117
+ connection.harness === harnessArg &&
118
+ connection.serverUrl === url.toString() &&
119
+ connection.directory === directoryArg,
120
+ );
121
+ if (duplicate) {
122
+ throw new Error(
123
+ `a ${harnessArg} connection already exists for ${url.toString()} and ${directoryArg}`,
124
+ );
125
+ }
126
+ const connectionId = `c-${plugins.crypto.randomBytes(8).toString('hex')}`;
127
+ const markClosingConnection = () => {
128
+ this.connections.delete(connectionId);
129
+ this.connecting.delete(connectionId);
130
+ this.closingConnections.set(connectionId, connection);
131
+ };
132
+ const removeClosedConnection = () => {
133
+ this.connections.delete(connectionId);
134
+ this.connecting.delete(connectionId);
135
+ this.closingConnections.delete(connectionId);
136
+ };
137
+ const connection: interfaces.IHarnessConnection =
138
+ harnessArg === 'opencode'
139
+ ? new OpencodeConnection(
140
+ connectionId,
141
+ url.toString(),
142
+ directoryArg,
143
+ markClosingConnection,
144
+ removeClosedConnection,
145
+ this.openCodeCredentials?.serverUrl === url.toString()
146
+ ? this.openCodeCredentials
147
+ : undefined,
148
+ )
149
+ : new CodexConnection(
150
+ connectionId,
151
+ url.toString(),
152
+ directoryArg,
153
+ markClosingConnection,
154
+ removeClosedConnection,
155
+ );
156
+ this.connecting.set(connectionId, connection);
157
+ let abortCleanupTask: Promise<void> | undefined;
158
+ const abortHandler = signalArg
159
+ ? () => {
160
+ abortCleanupTask ??= this.closeConnection(
161
+ connection,
162
+ this.abortError(signalArg.reason).message,
163
+ );
164
+ void abortCleanupTask.catch(() => undefined);
165
+ }
166
+ : undefined;
167
+ if (abortHandler) signalArg!.addEventListener('abort', abortHandler, { once: true });
168
+ try {
169
+ await connection.connect();
170
+ if (signalArg?.aborted) throw this.abortError(signalArg.reason);
171
+ if (this.closing) {
172
+ throw new Error('connection registry closed during setup');
173
+ }
174
+ this.connections.set(connectionId, connection);
175
+ return connection.getInfo();
176
+ } catch (errorArg) {
177
+ const operationError = signalArg?.aborted ? this.abortError(signalArg.reason) : errorArg;
178
+ try {
179
+ await (abortCleanupTask ?? this.closeConnection(connection, 'connection setup failed'));
180
+ } catch (cleanupErrorArg) {
181
+ throw new AggregateError(
182
+ [operationError, cleanupErrorArg],
183
+ 'Connection setup failed and cleanup is incomplete.',
184
+ );
185
+ }
186
+ throw operationError;
187
+ } finally {
188
+ if (abortHandler) signalArg!.removeEventListener('abort', abortHandler);
189
+ this.connecting.delete(connectionId);
190
+ }
191
+ }
192
+
193
+ public get(connectionIdArg: string): interfaces.IHarnessConnection {
194
+ const connection = this.connections.get(connectionIdArg);
195
+ if (!connection) {
196
+ throw new Error(`unknown connectionId ${connectionIdArg}`);
197
+ }
198
+ return connection;
199
+ }
200
+
201
+ public list(): interfaces.IConnectionInfo[] {
202
+ return [...this.connections.values()].map((connection) => connection.getInfo());
203
+ }
204
+
205
+ public async disconnect(connectionIdArg: string): Promise<interfaces.IConnectionInfo> {
206
+ const connection = this.connections.get(connectionIdArg)
207
+ ?? this.closingConnections.get(connectionIdArg);
208
+ if (!connection) throw new Error(`unknown connectionId ${connectionIdArg}`);
209
+ await this.closeConnection(connection, 'connection explicitly disconnected');
210
+ return connection.getInfo();
211
+ }
212
+
213
+ public async closeAll(): Promise<void> {
214
+ if (this.shutdownPromise) return this.shutdownPromise;
215
+ this.closing = true;
216
+ const connections = [
217
+ ...new Set([
218
+ ...this.connections.values(),
219
+ ...this.connecting.values(),
220
+ ...this.closingConnections.values(),
221
+ ]),
222
+ ];
223
+ const shutdownPromise = Promise.allSettled(
224
+ connections.map((connection) => this.closeConnection(
225
+ connection,
226
+ 'Crossharness MCP transport closed',
227
+ )),
228
+ ).then((results) => {
229
+ const errors = results.flatMap((result) => (
230
+ result.status === 'rejected' ? [result.reason] : []
231
+ ));
232
+ if (errors.length === 1) throw errors[0];
233
+ if (errors.length > 1) {
234
+ throw new AggregateError(errors, 'Closing Crossharness connections failed.');
235
+ }
236
+ });
237
+ this.shutdownPromise = shutdownPromise;
238
+ try {
239
+ await shutdownPromise;
240
+ } finally {
241
+ if (this.shutdownPromise === shutdownPromise) this.shutdownPromise = undefined;
242
+ }
243
+ }
244
+
245
+ private closeConnection(
246
+ connectionArg: interfaces.IHarnessConnection,
247
+ reasonArg: string,
248
+ ): Promise<void> {
249
+ const connectionId = connectionArg.connectionId;
250
+ const existing = this.closingTasks.get(connectionId);
251
+ if (existing) return existing;
252
+ this.connections.delete(connectionId);
253
+ this.connecting.delete(connectionId);
254
+ this.closingConnections.set(connectionId, connectionArg);
255
+ let task!: Promise<void>;
256
+ task = Promise.resolve().then(() => connectionArg.close(reasonArg)).then(
257
+ () => {
258
+ if (this.closingConnections.get(connectionId) === connectionArg) {
259
+ this.closingConnections.delete(connectionId);
260
+ }
261
+ },
262
+ (errorArg) => {
263
+ this.connections.delete(connectionId);
264
+ this.connecting.delete(connectionId);
265
+ this.closingConnections.set(connectionId, connectionArg);
266
+ throw errorArg;
267
+ },
268
+ ).finally(() => {
269
+ if (this.closingTasks.get(connectionId) === task) this.closingTasks.delete(connectionId);
270
+ });
271
+ this.closingTasks.set(connectionId, task);
272
+ return task;
273
+ }
274
+
275
+ private validateAllowedUrl(harnessArg: interfaces.THarnessName, valueArg: string): URL {
276
+ const url = this.normalizeUrl(valueArg);
277
+ if (url.username || url.password || url.search || url.hash) {
278
+ throw new Error('server URL must not contain credentials, query parameters, or a fragment');
279
+ }
280
+ if (url.pathname !== '/') {
281
+ throw new Error('server URL must be an origin with no path');
282
+ }
283
+ const protocols = harnessArg === 'opencode' ? ['http:', 'https:'] : ['ws:', 'wss:'];
284
+ if (!protocols.includes(url.protocol)) {
285
+ throw new Error(`${harnessArg} server URL must use ${protocols.join(' or ')}`);
286
+ }
287
+ if (!this.allowedServerUrls.has(url.toString())) {
288
+ throw new Error(
289
+ `server URL ${url.toString()} is not in CROSSHARNESS_ALLOWED_SERVER_URLS`,
290
+ );
291
+ }
292
+ return url;
293
+ }
294
+
295
+ private normalizeUrl(valueArg: string): URL {
296
+ let url: URL;
297
+ try {
298
+ url = new URL(valueArg);
299
+ } catch {
300
+ throw new Error(`invalid server URL: ${valueArg}`);
301
+ }
302
+ url.pathname = url.pathname || '/';
303
+ return url;
304
+ }
305
+
306
+ private abortError(reasonArg: unknown): Error {
307
+ return reasonArg instanceof Error ? reasonArg : new Error('connection cancelled');
308
+ }
309
+ }
@@ -0,0 +1,216 @@
1
+ import type * as interfaces from './interfaces.js';
2
+
3
+ export type TDispatchStatus = 'working' | 'completed' | 'failed';
4
+
5
+ export interface IDispatchRecord {
6
+ dispatchId: string;
7
+ connectionId: string;
8
+ harness: interfaces.THarnessName;
9
+ chatId: string;
10
+ startedAt: string;
11
+ completedAt?: string;
12
+ status: TDispatchStatus;
13
+ reply?: string;
14
+ via?: string;
15
+ error?: string;
16
+ }
17
+
18
+ interface IDispatchWaiter {
19
+ resolve: (recordArg: IDispatchRecord | undefined) => void;
20
+ timer: ReturnType<typeof setTimeout>;
21
+ signal?: AbortSignal;
22
+ abortHandler?: () => void;
23
+ }
24
+
25
+ export interface IDispatchRegistryOptions {
26
+ maxRecords?: number;
27
+ completedTtlMs?: number;
28
+ }
29
+
30
+ export class DispatchRegistry {
31
+ private records = new Map<string, IDispatchRecord>();
32
+ private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
33
+ private waiters = new Map<string, Set<IDispatchWaiter>>();
34
+ private waiterCount = 0;
35
+ private counter = 0;
36
+ private instancePrefix = Math.random().toString(36).slice(2, 7);
37
+ private disposed = false;
38
+
39
+ constructor(private options: IDispatchRegistryOptions = {}) {
40
+ if (!Number.isInteger(options.maxRecords ?? 100) || (options.maxRecords ?? 100) < 1) {
41
+ throw new Error('dispatch maxRecords must be a positive integer');
42
+ }
43
+ if ((options.completedTtlMs ?? 2 * 60 * 60 * 1000) < 0) {
44
+ throw new Error('dispatch completedTtlMs must not be negative');
45
+ }
46
+ }
47
+
48
+ public track(
49
+ connectionIdArg: string,
50
+ harnessArg: interfaces.THarnessName,
51
+ chatIdArg: string,
52
+ sendFactoryArg: () => Promise<interfaces.ISendResult>,
53
+ ): IDispatchRecord {
54
+ if (this.disposed) throw new Error('dispatch registry is closed');
55
+ this.reserveCapacity();
56
+ const record: IDispatchRecord = {
57
+ dispatchId: `d-${this.instancePrefix}-${++this.counter}`,
58
+ connectionId: connectionIdArg,
59
+ harness: harnessArg,
60
+ chatId: chatIdArg,
61
+ startedAt: new Date().toISOString(),
62
+ status: 'working',
63
+ };
64
+ this.records.set(record.dispatchId, record);
65
+ Promise.resolve().then(sendFactoryArg).then(
66
+ (result) => {
67
+ if (this.disposed || record.status !== 'working') return;
68
+ record.status = 'completed';
69
+ record.completedAt = new Date().toISOString();
70
+ record.reply = result.reply.slice(0, 100000);
71
+ record.via = result.via;
72
+ this.notifyWaiters(record.dispatchId, record);
73
+ this.scheduleExpiry(record.dispatchId);
74
+ this.prune();
75
+ },
76
+ (error) => {
77
+ if (this.disposed || record.status !== 'working') return;
78
+ record.status = 'failed';
79
+ record.completedAt = new Date().toISOString();
80
+ record.error = (error instanceof Error ? error.message : String(error)).slice(0, 4000);
81
+ this.notifyWaiters(record.dispatchId, record);
82
+ this.scheduleExpiry(record.dispatchId);
83
+ this.prune();
84
+ },
85
+ );
86
+ return record;
87
+ }
88
+
89
+ public get(connectionIdArg: string, dispatchIdArg: string): IDispatchRecord | undefined {
90
+ this.prune();
91
+ const record = this.records.get(dispatchIdArg);
92
+ return record?.connectionId === connectionIdArg ? record : undefined;
93
+ }
94
+
95
+ public async waitFor(
96
+ connectionIdArg: string,
97
+ dispatchIdArg: string,
98
+ waitMsArg: number,
99
+ signalArg?: AbortSignal,
100
+ ): Promise<IDispatchRecord | undefined> {
101
+ const deadline = Date.now() + waitMsArg;
102
+ const record = this.get(connectionIdArg, dispatchIdArg);
103
+ if (!record || record.status !== 'working' || waitMsArg <= 0) return record;
104
+ if (signalArg?.aborted) return record;
105
+ if (this.waiterCount >= 1000) return record;
106
+ return new Promise<IDispatchRecord | undefined>((resolve) => {
107
+ const waiter: IDispatchWaiter = {
108
+ resolve,
109
+ timer: setTimeout(() => {
110
+ this.removeWaiter(dispatchIdArg, waiter);
111
+ resolve(this.get(connectionIdArg, dispatchIdArg));
112
+ }, Math.max(0, deadline - Date.now())),
113
+ signal: signalArg,
114
+ };
115
+ if (signalArg) {
116
+ waiter.abortHandler = () => {
117
+ this.removeWaiter(dispatchIdArg, waiter);
118
+ resolve(this.get(connectionIdArg, dispatchIdArg));
119
+ };
120
+ signalArg.addEventListener('abort', waiter.abortHandler, { once: true });
121
+ }
122
+ const dispatchWaiters = this.waiters.get(dispatchIdArg) ?? new Set();
123
+ dispatchWaiters.add(waiter);
124
+ this.waiterCount++;
125
+ this.waiters.set(dispatchIdArg, dispatchWaiters);
126
+ const current = this.get(connectionIdArg, dispatchIdArg);
127
+ if (!current || current.status !== 'working') {
128
+ this.notifyWaiters(dispatchIdArg, current);
129
+ }
130
+ });
131
+ }
132
+
133
+ public dispose(): void {
134
+ if (this.disposed) return;
135
+ this.disposed = true;
136
+ for (const timer of this.expiryTimers.values()) clearTimeout(timer);
137
+ this.expiryTimers.clear();
138
+ for (const dispatchId of [...this.waiters.keys()]) {
139
+ this.notifyWaiters(dispatchId, undefined);
140
+ }
141
+ this.records.clear();
142
+ }
143
+
144
+ private prune(): void {
145
+ const completedTtlMs = this.options.completedTtlMs ?? 2 * 60 * 60 * 1000;
146
+ const now = Date.now();
147
+ for (const [dispatchId, record] of this.records) {
148
+ if (
149
+ record.status !== 'working' &&
150
+ record.completedAt &&
151
+ now - Date.parse(record.completedAt) > completedTtlMs
152
+ ) {
153
+ this.forget(dispatchId);
154
+ }
155
+ }
156
+ }
157
+
158
+ private scheduleExpiry(dispatchIdArg: string): void {
159
+ const existingTimer = this.expiryTimers.get(dispatchIdArg);
160
+ if (existingTimer) clearTimeout(existingTimer);
161
+ const timer = setTimeout(() => {
162
+ this.expiryTimers.delete(dispatchIdArg);
163
+ this.records.delete(dispatchIdArg);
164
+ }, this.options.completedTtlMs ?? 2 * 60 * 60 * 1000);
165
+ this.expiryTimers.set(dispatchIdArg, timer);
166
+ timer.unref();
167
+ }
168
+
169
+ private reserveCapacity(): void {
170
+ this.prune();
171
+ const maxRecords = this.options.maxRecords ?? 100;
172
+ for (const [dispatchId, record] of this.records) {
173
+ if (this.records.size < maxRecords) break;
174
+ if (record.status !== 'working') this.forget(dispatchId);
175
+ }
176
+ if (this.records.size >= maxRecords) {
177
+ throw new Error('dispatch registry is full of active sends');
178
+ }
179
+ }
180
+
181
+ private forget(dispatchIdArg: string): void {
182
+ this.records.delete(dispatchIdArg);
183
+ const timer = this.expiryTimers.get(dispatchIdArg);
184
+ if (timer) clearTimeout(timer);
185
+ this.expiryTimers.delete(dispatchIdArg);
186
+ this.notifyWaiters(dispatchIdArg, undefined);
187
+ }
188
+
189
+ private notifyWaiters(
190
+ dispatchIdArg: string,
191
+ recordArg: IDispatchRecord | undefined,
192
+ ): void {
193
+ const dispatchWaiters = this.waiters.get(dispatchIdArg);
194
+ if (!dispatchWaiters) return;
195
+ this.waiters.delete(dispatchIdArg);
196
+ this.waiterCount -= dispatchWaiters.size;
197
+ for (const waiter of dispatchWaiters) {
198
+ clearTimeout(waiter.timer);
199
+ if (waiter.abortHandler) waiter.signal?.removeEventListener('abort', waiter.abortHandler);
200
+ waiter.resolve(recordArg);
201
+ }
202
+ }
203
+
204
+ private removeWaiter(dispatchIdArg: string, waiterArg: IDispatchWaiter): void {
205
+ const dispatchWaiters = this.waiters.get(dispatchIdArg);
206
+ if (!dispatchWaiters) return;
207
+ if (dispatchWaiters.delete(waiterArg)) {
208
+ this.waiterCount--;
209
+ clearTimeout(waiterArg.timer);
210
+ if (waiterArg.abortHandler) {
211
+ waiterArg.signal?.removeEventListener('abort', waiterArg.abortHandler);
212
+ }
213
+ }
214
+ if (dispatchWaiters.size === 0) this.waiters.delete(dispatchIdArg);
215
+ }
216
+ }