@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,443 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as helpers from './helpers.js';
3
+ import type * as interfaces from './interfaces.js';
4
+ import type { IOpenCodeCredentials } from './classes.connectionregistry.js';
5
+
6
+ const serverApiVersion = '1.18.13';
7
+
8
+ interface IOpencodeRequestOptions {
9
+ method?: 'GET' | 'POST';
10
+ query?: Record<string, string>;
11
+ body?: unknown;
12
+ timeoutMs: number;
13
+ signal?: AbortSignal;
14
+ }
15
+
16
+ export class OpencodeConnection implements interfaces.IHarnessConnection {
17
+ public readonly harness = 'opencode' as const;
18
+ public state: interfaces.TConnectionState = 'connecting';
19
+ private connectedAt = new Date().toISOString();
20
+ private serverVersion: string | undefined;
21
+ private activeControllers = new Set<AbortController>();
22
+ private connectStarted = false;
23
+
24
+ constructor(
25
+ public readonly connectionId: string,
26
+ public readonly serverUrl: string,
27
+ public readonly directory: string,
28
+ private onClosing?: () => void,
29
+ private onClosed?: () => void,
30
+ private readonly credentials?: IOpenCodeCredentials,
31
+ ) {}
32
+
33
+ public async connect(): Promise<void> {
34
+ if (this.connectStarted) {
35
+ throw new Error(`OpenCode connection ${this.connectionId} has already been started`);
36
+ }
37
+ this.connectStarted = true;
38
+ const health = await this.requestJson('/global/health', { timeoutMs: 5000 }, true);
39
+ if (!helpers.isRecord(health) || health.healthy !== true || typeof health.version !== 'string') {
40
+ throw new Error(
41
+ `OpenCode server at ${this.serverUrl} returned an invalid health response (expected API verified against ${serverApiVersion})`,
42
+ );
43
+ }
44
+ this.serverVersion = health.version;
45
+ this.state = 'connected';
46
+ }
47
+
48
+ public getInfo(): interfaces.IConnectionInfo {
49
+ return {
50
+ connectionId: this.connectionId,
51
+ harness: this.harness,
52
+ serverUrl: this.serverUrl,
53
+ directory: this.directory,
54
+ state: this.state,
55
+ connectedAt: this.connectedAt,
56
+ serverVersion: this.serverVersion,
57
+ };
58
+ }
59
+
60
+ public async getStatus(): Promise<interfaces.IConnectionStatus> {
61
+ if (this.state !== 'connected') {
62
+ return { ...this.getInfo(), healthy: false, detail: `connection is ${this.state}` };
63
+ }
64
+ try {
65
+ const health = await this.requestJson('/global/health', { timeoutMs: 5000 });
66
+ const healthy = helpers.isRecord(health) && health.healthy === true;
67
+ return {
68
+ ...this.getInfo(),
69
+ healthy,
70
+ detail: healthy ? 'OpenCode /global/health reported healthy' : 'invalid health response',
71
+ };
72
+ } catch (error) {
73
+ return { ...this.getInfo(), healthy: false, detail: helpers.errorMessage(error) };
74
+ }
75
+ }
76
+
77
+ public async listChats(limitArg: number): Promise<interfaces.IChatSummary[]> {
78
+ const payload = await this.requestJson('/session', {
79
+ query: { directory: this.directory, roots: 'true', limit: String(limitArg) },
80
+ timeoutMs: 30000,
81
+ });
82
+ if (!Array.isArray(payload)) {
83
+ throw new Error('OpenCode server returned an invalid session list');
84
+ }
85
+ const sessions = payload.map((entry) => this.parseSession(entry));
86
+ if (sessions.some((session) => session.directory !== this.directory)) {
87
+ throw new Error('OpenCode server returned a session outside the connection directory');
88
+ }
89
+ return sessions
90
+ .filter((session) => session.parentID === undefined)
91
+ .slice(0, limitArg)
92
+ .map((session) => ({
93
+ harness: this.harness,
94
+ chatId: session.id,
95
+ title: session.title,
96
+ directory: session.directory,
97
+ updatedAt: new Date(session.updatedMs).toISOString(),
98
+ isActive: Date.now() - session.updatedMs < 120000,
99
+ }));
100
+ }
101
+
102
+ public async readChat(
103
+ chatIdArg: string,
104
+ limitArg: number,
105
+ maxCharsArg: number,
106
+ ): Promise<interfaces.IChatTranscript> {
107
+ this.assertChatId(chatIdArg);
108
+ const session = await this.requireSession(chatIdArg);
109
+ const payload = await this.requestJson(`/session/${encodeURIComponent(chatIdArg)}/message`, {
110
+ query: { directory: this.directory, limit: String(limitArg) },
111
+ timeoutMs: 30000,
112
+ });
113
+ if (!Array.isArray(payload)) {
114
+ throw new Error('OpenCode server returned an invalid message list');
115
+ }
116
+ const messages = payload.map((entry) => this.parseMessage(entry, chatIdArg, maxCharsArg));
117
+ return {
118
+ harness: this.harness,
119
+ chatId: chatIdArg,
120
+ title: session.title,
121
+ directory: session.directory,
122
+ messages,
123
+ };
124
+ }
125
+
126
+ public async sendMessage(
127
+ chatIdArg: string,
128
+ messageArg: string,
129
+ timeoutSecondsArg: number,
130
+ signalArg?: AbortSignal,
131
+ ): Promise<interfaces.ISendResult> {
132
+ this.assertChatId(chatIdArg);
133
+ if (messageArg.length > 1024 * 1024) {
134
+ throw new Error('OpenCode message exceeds the 1 MiB character limit');
135
+ }
136
+ const deadline = Date.now() + timeoutSecondsArg * 1000;
137
+ await this.requireSession(
138
+ chatIdArg,
139
+ this.remainingSendMs(deadline, timeoutSecondsArg),
140
+ signalArg,
141
+ );
142
+ const requestMessageId = `msg_${plugins.crypto.randomUUID().replaceAll('-', '')}`;
143
+ let payload: unknown;
144
+ try {
145
+ payload = await this.requestJson(`/session/${encodeURIComponent(chatIdArg)}/message`, {
146
+ method: 'POST',
147
+ query: { directory: this.directory },
148
+ body: {
149
+ messageID: requestMessageId,
150
+ parts: [{ type: 'text', text: messageArg }],
151
+ },
152
+ timeoutMs: this.remainingSendMs(deadline, timeoutSecondsArg),
153
+ signal: signalArg,
154
+ });
155
+ } catch (error) {
156
+ throw new Error(
157
+ `OpenCode send failed; the server may still have queued or started the turn, and Crossharness did not retry: ${helpers.errorMessage(error)}`,
158
+ { cause: error },
159
+ );
160
+ }
161
+ const parsed = this.parseMessageEnvelope(payload, chatIdArg);
162
+ if (parsed.info.role !== 'assistant' || parsed.info.parentID !== requestMessageId) {
163
+ throw new Error('OpenCode server returned an assistant response for a different prompt');
164
+ }
165
+ if (parsed.info.error !== undefined && parsed.info.error !== null) {
166
+ throw new Error('OpenCode server returned an assistant error');
167
+ }
168
+ const reply = parsed.parts
169
+ .filter((part) => part.type === 'text' && typeof part.text === 'string')
170
+ .map((part) => part.text as string)
171
+ .join('\n')
172
+ .trim();
173
+ if (!reply) {
174
+ throw new Error('OpenCode server returned no assistant text');
175
+ }
176
+ return {
177
+ harness: this.harness,
178
+ chatId: chatIdArg,
179
+ reply,
180
+ via: `http:${this.serverUrl}`,
181
+ };
182
+ }
183
+
184
+ public async close(reasonArg = 'connection disconnected'): Promise<void> {
185
+ if (this.state === 'closed') return;
186
+ this.state = 'closing';
187
+ this.onClosing?.();
188
+ this.onClosing = undefined;
189
+ for (const controller of this.activeControllers) {
190
+ controller.abort(new Error(reasonArg));
191
+ }
192
+ this.activeControllers.clear();
193
+ this.state = 'closed';
194
+ this.onClosed?.();
195
+ this.onClosed = undefined;
196
+ }
197
+
198
+ private async requireSession(
199
+ chatIdArg: string,
200
+ timeoutMsArg = 30000,
201
+ signalArg?: AbortSignal,
202
+ ): Promise<{
203
+ id: string;
204
+ title: string;
205
+ directory: string;
206
+ updatedMs: number;
207
+ parentID?: string;
208
+ }> {
209
+ const payload = await this.requestJson(`/session/${encodeURIComponent(chatIdArg)}`, {
210
+ query: { directory: this.directory },
211
+ timeoutMs: timeoutMsArg,
212
+ signal: signalArg,
213
+ });
214
+ const session = this.parseSession(payload);
215
+ if (session.id !== chatIdArg) {
216
+ throw new Error(`OpenCode server returned a different session for ${chatIdArg}`);
217
+ }
218
+ if (session.directory !== this.directory) {
219
+ throw new Error(`OpenCode session ${chatIdArg} is outside the connection directory`);
220
+ }
221
+ return session;
222
+ }
223
+
224
+ private parseSession(valueArg: unknown): {
225
+ id: string;
226
+ title: string;
227
+ directory: string;
228
+ updatedMs: number;
229
+ parentID?: string;
230
+ } {
231
+ if (!helpers.isRecord(valueArg) || !helpers.isRecord(valueArg.time)) {
232
+ throw new Error('OpenCode server returned an invalid session');
233
+ }
234
+ const updatedMs = Number(valueArg.time.updated);
235
+ if (
236
+ typeof valueArg.id !== 'string' ||
237
+ typeof valueArg.title !== 'string' ||
238
+ typeof valueArg.directory !== 'string' ||
239
+ !Number.isFinite(updatedMs) ||
240
+ (valueArg.parentID !== undefined && typeof valueArg.parentID !== 'string')
241
+ ) {
242
+ throw new Error('OpenCode server returned an invalid session');
243
+ }
244
+ return {
245
+ id: valueArg.id,
246
+ title: valueArg.title,
247
+ directory: valueArg.directory,
248
+ updatedMs,
249
+ parentID: valueArg.parentID,
250
+ };
251
+ }
252
+
253
+ private parseMessage(
254
+ valueArg: unknown,
255
+ chatIdArg: string,
256
+ maxCharsArg: number,
257
+ ): interfaces.IChatMessage {
258
+ const parsed = this.parseMessageEnvelope(valueArg, chatIdArg);
259
+ const textParts = parsed.parts
260
+ .filter((part) => part.type === 'text' && typeof part.text === 'string')
261
+ .map((part) => part.text as string);
262
+ const toolParts = parsed.parts
263
+ .filter((part) => part.type === 'tool' && typeof part.tool === 'string')
264
+ .map((part) => `[tool: ${part.tool as string}]`);
265
+ const created = helpers.isRecord(parsed.info.time) ? Number(parsed.info.time.created) : NaN;
266
+ return {
267
+ role: parsed.info.role,
268
+ text: helpers.truncateText([...textParts, ...toolParts].join('\n').trim(), maxCharsArg),
269
+ timestamp: Number.isFinite(created) ? new Date(created).toISOString() : undefined,
270
+ };
271
+ }
272
+
273
+ private parseMessageEnvelope(valueArg: unknown, chatIdArg: string): {
274
+ info: Record<string, unknown> & { id: string; role: string; sessionID: string; parentID?: string };
275
+ parts: Array<Record<string, unknown> & { type: string }>;
276
+ } {
277
+ if (!helpers.isRecord(valueArg) || !helpers.isRecord(valueArg.info) || !Array.isArray(valueArg.parts)) {
278
+ throw new Error('OpenCode server returned an invalid message');
279
+ }
280
+ const info = valueArg.info;
281
+ if (
282
+ typeof info.id !== 'string' ||
283
+ typeof info.role !== 'string' ||
284
+ info.sessionID !== chatIdArg ||
285
+ (info.parentID !== undefined && typeof info.parentID !== 'string')
286
+ ) {
287
+ throw new Error('OpenCode server returned an invalid message identity');
288
+ }
289
+ const parts = valueArg.parts.map((part) => {
290
+ if (
291
+ !helpers.isRecord(part) ||
292
+ typeof part.type !== 'string' ||
293
+ part.sessionID !== chatIdArg ||
294
+ part.messageID !== info.id
295
+ ) {
296
+ throw new Error('OpenCode server returned an invalid message part');
297
+ }
298
+ return part as Record<string, unknown> & { type: string };
299
+ });
300
+ return {
301
+ info: info as Record<string, unknown> & {
302
+ id: string;
303
+ role: string;
304
+ sessionID: string;
305
+ parentID?: string;
306
+ },
307
+ parts,
308
+ };
309
+ }
310
+
311
+ private async requestJson(
312
+ pathArg: string,
313
+ optionsArg: IOpencodeRequestOptions,
314
+ allowConnectingArg = false,
315
+ ): Promise<unknown> {
316
+ if (!allowConnectingArg && this.state !== 'connected') {
317
+ throw new Error(`OpenCode connection ${this.connectionId} is ${this.state}`);
318
+ }
319
+ const url = new URL(pathArg, `${this.serverUrl}/`);
320
+ for (const [key, value] of Object.entries(optionsArg.query ?? {})) {
321
+ url.searchParams.set(key, value);
322
+ }
323
+ const controller = new AbortController();
324
+ if (this.activeControllers.size >= 100) {
325
+ throw new Error('OpenCode connection has too many active requests');
326
+ }
327
+ this.activeControllers.add(controller);
328
+ const abortFromCaller = () => controller.abort(optionsArg.signal?.reason);
329
+ if (optionsArg.signal?.aborted) abortFromCaller();
330
+ else optionsArg.signal?.addEventListener('abort', abortFromCaller, { once: true });
331
+ const timer = setTimeout(
332
+ () => controller.abort(new Error(`OpenCode request timed out after ${optionsArg.timeoutMs}ms`)),
333
+ optionsArg.timeoutMs,
334
+ );
335
+ try {
336
+ const response = await fetch(url, {
337
+ method: optionsArg.method ?? 'GET',
338
+ headers: {
339
+ ...this.authHeaders(),
340
+ accept: 'application/json',
341
+ ...(optionsArg.body === undefined ? {} : { 'content-type': 'application/json' }),
342
+ },
343
+ body: optionsArg.body === undefined ? undefined : JSON.stringify(optionsArg.body),
344
+ redirect: 'error',
345
+ signal: controller.signal,
346
+ });
347
+ if (!response.ok) {
348
+ const responseText = await this.readResponseText(response, 64 * 1024).catch(
349
+ () => '[response body unavailable or too large]',
350
+ );
351
+ const redactedResponseText = this.redactCredentials(responseText);
352
+ throw new Error(
353
+ `OpenCode server returned HTTP ${response.status}${redactedResponseText ? `: ${redactedResponseText.slice(0, 2000)}` : ''}`,
354
+ );
355
+ }
356
+ const responseText = await this.readResponseText(response, 16 * 1024 * 1024);
357
+ const payload = helpers.parseJson(responseText);
358
+ if (payload === undefined) {
359
+ throw new Error('OpenCode server returned invalid JSON');
360
+ }
361
+ return payload;
362
+ } finally {
363
+ clearTimeout(timer);
364
+ optionsArg.signal?.removeEventListener('abort', abortFromCaller);
365
+ this.activeControllers.delete(controller);
366
+ }
367
+ }
368
+
369
+ private async readResponseText(responseArg: Response, maxBytesArg: number): Promise<string> {
370
+ const contentLength = Number(responseArg.headers.get('content-length'));
371
+ if (Number.isFinite(contentLength) && contentLength > maxBytesArg) {
372
+ await responseArg.body?.cancel().catch(() => {});
373
+ throw new Error(`OpenCode response exceeds ${maxBytesArg} bytes`);
374
+ }
375
+ if (!responseArg.body) return '';
376
+ const reader = responseArg.body.getReader();
377
+ const decoder = new TextDecoder();
378
+ let bytes = 0;
379
+ let text = '';
380
+ try {
381
+ while (true) {
382
+ const { done, value } = await reader.read();
383
+ if (done) break;
384
+ bytes += value.byteLength;
385
+ if (bytes > maxBytesArg) {
386
+ await reader.cancel().catch(() => {});
387
+ throw new Error(`OpenCode response exceeds ${maxBytesArg} bytes`);
388
+ }
389
+ text += decoder.decode(value, { stream: true });
390
+ }
391
+ return text + decoder.decode();
392
+ } finally {
393
+ reader.releaseLock();
394
+ }
395
+ }
396
+
397
+ private authHeaders(): Record<string, string> {
398
+ const password = this.credentials?.password ?? process.env.OPENCODE_SERVER_PASSWORD;
399
+ if (!password) {
400
+ throw new Error('OPENCODE_SERVER_PASSWORD is required for OpenCode connections');
401
+ }
402
+ const username = this.credentials
403
+ ? (this.credentials.username ?? 'opencode')
404
+ : (process.env.OPENCODE_SERVER_USERNAME ?? 'opencode');
405
+ return {
406
+ authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
407
+ };
408
+ }
409
+
410
+ private redactCredentials(valueArg: string): string {
411
+ const password = this.credentials?.password ?? process.env.OPENCODE_SERVER_PASSWORD;
412
+ const username = this.credentials
413
+ ? (this.credentials.username ?? 'opencode')
414
+ : (process.env.OPENCODE_SERVER_USERNAME ?? 'opencode');
415
+ const authorization = password
416
+ ? `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
417
+ : '';
418
+ let value = valueArg;
419
+ const secrets = [authorization, password, username]
420
+ .filter((secret): secret is string => Boolean(secret))
421
+ .sort((left, right) => right.length - left.length);
422
+ for (const secret of secrets) {
423
+ if (secret) value = value.replaceAll(secret, '[redacted]');
424
+ }
425
+ return value;
426
+ }
427
+
428
+ private remainingSendMs(deadlineArg: number, timeoutSecondsArg: number): number {
429
+ const remainingMs = deadlineArg - Date.now();
430
+ if (remainingMs <= 0) {
431
+ throw new Error(
432
+ `OpenCode send timed out after ${timeoutSecondsArg}s before dispatch; no message was delivered`,
433
+ );
434
+ }
435
+ return remainingMs;
436
+ }
437
+
438
+ private assertChatId(chatIdArg: string): void {
439
+ if (!chatIdArg || chatIdArg.length > 1024) {
440
+ throw new Error('OpenCode chatId must contain 1-1024 characters');
441
+ }
442
+ }
443
+ }