@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.
- package/.smartconfig.json +34 -0
- package/cli.js +4 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.connection.codex.d.ts +66 -0
- package/dist_ts/classes.connection.codex.js +941 -0
- package/dist_ts/classes.connection.opencode.d.ts +34 -0
- package/dist_ts/classes.connection.opencode.js +365 -0
- package/dist_ts/classes.connectionregistry.d.ts +32 -0
- package/dist_ts/classes.connectionregistry.js +246 -0
- package/dist_ts/classes.dispatchregistry.d.ts +39 -0
- package/dist_ts/classes.dispatchregistry.js +177 -0
- package/dist_ts/classes.mcpserver.d.ts +21 -0
- package/dist_ts/classes.mcpserver.js +276 -0
- package/dist_ts/helpers.d.ts +4 -0
- package/dist_ts/helpers.js +17 -0
- package/dist_ts/index.d.ts +5 -0
- package/dist_ts/index.js +9 -0
- package/dist_ts/interfaces.d.ts +57 -0
- package/dist_ts/interfaces.js +2 -0
- package/dist_ts/plugins.d.ts +17 -0
- package/dist_ts/plugins.js +14 -0
- package/license.md +21 -0
- package/package.json +51 -0
- package/readme.hints.md +52 -0
- package/readme.md +204 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.connection.codex.ts +1120 -0
- package/ts/classes.connection.opencode.ts +443 -0
- package/ts/classes.connectionregistry.ts +309 -0
- package/ts/classes.dispatchregistry.ts +216 -0
- package/ts/classes.mcpserver.ts +363 -0
- package/ts/helpers.ts +20 -0
- package/ts/index.ts +10 -0
- package/ts/interfaces.ts +71 -0
- package/ts/plugins.ts +30 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import * as helpers from './helpers.js';
|
|
3
|
+
import { commitinfo } from './00_commitinfo_data.js';
|
|
4
|
+
import type * as interfaces from './interfaces.js';
|
|
5
|
+
|
|
6
|
+
const appServerVersion = '0.146.0';
|
|
7
|
+
const maxSocketMessageBytes = 4 * 1024 * 1024;
|
|
8
|
+
const maxSocketBufferedBytes = 4 * 1024 * 1024;
|
|
9
|
+
const maxTurnTextChars = 1024 * 1024;
|
|
10
|
+
const maxTurnDeltaChunks = 4096;
|
|
11
|
+
const maxEarlyNotifications = 100;
|
|
12
|
+
const maxQueuedSends = 100;
|
|
13
|
+
const maxQueuedSendsPerThread = 10;
|
|
14
|
+
|
|
15
|
+
interface ICodexThread {
|
|
16
|
+
id: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
updatedAt: number;
|
|
19
|
+
turns: unknown[];
|
|
20
|
+
preview?: string;
|
|
21
|
+
name?: string | null;
|
|
22
|
+
modelProvider?: string;
|
|
23
|
+
status?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ICodexNotification {
|
|
27
|
+
method: string;
|
|
28
|
+
params: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface IPendingRequest {
|
|
32
|
+
resolve: (valueArg: unknown) => void;
|
|
33
|
+
reject: (errorArg: Error) => void;
|
|
34
|
+
timer: ReturnType<typeof setTimeout>;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
abortHandler?: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface IPendingTurn {
|
|
40
|
+
threadId: string;
|
|
41
|
+
turnId?: string;
|
|
42
|
+
agentText: string;
|
|
43
|
+
deltaChunks: string[];
|
|
44
|
+
deltaChars: number;
|
|
45
|
+
earlyNotifications: Map<string, ICodexNotification[]>;
|
|
46
|
+
earlyNotificationCount: number;
|
|
47
|
+
earlyNotificationChars: number;
|
|
48
|
+
timeoutError?: Error;
|
|
49
|
+
interruptAcknowledged?: boolean;
|
|
50
|
+
terminalObserved?: boolean;
|
|
51
|
+
terminalTimer?: ReturnType<typeof setTimeout>;
|
|
52
|
+
resolve: (valueArg: interfaces.ISendResult) => void;
|
|
53
|
+
reject: (errorArg: Error) => void;
|
|
54
|
+
timer: ReturnType<typeof setTimeout>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface IThreadQueueEntry {
|
|
58
|
+
deadline: number;
|
|
59
|
+
timeoutSeconds: number;
|
|
60
|
+
task: () => Promise<interfaces.ISendResult>;
|
|
61
|
+
resolve: (valueArg: interfaces.ISendResult) => void;
|
|
62
|
+
reject: (errorArg: Error) => void;
|
|
63
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
abortHandler?: () => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class CodexConnection implements interfaces.IHarnessConnection {
|
|
69
|
+
public readonly harness = 'codex' as const;
|
|
70
|
+
public state: interfaces.TConnectionState = 'connecting';
|
|
71
|
+
private connectedAt = new Date().toISOString();
|
|
72
|
+
private serverVersion: string | undefined;
|
|
73
|
+
private socket: InstanceType<typeof plugins.WsWebSocket> | undefined;
|
|
74
|
+
private requestCounter = 0;
|
|
75
|
+
private pendingRequests = new Map<number, IPendingRequest>();
|
|
76
|
+
private pendingTurns = new Set<IPendingTurn>();
|
|
77
|
+
private threadQueues = new Map<string, IThreadQueueEntry[]>();
|
|
78
|
+
private activeThreads = new Set<string>();
|
|
79
|
+
private queuedSends = 0;
|
|
80
|
+
private queuedSendsByThread = new Map<string, number>();
|
|
81
|
+
private connectStarted = false;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
public readonly connectionId: string,
|
|
85
|
+
public readonly serverUrl: string,
|
|
86
|
+
public readonly directory: string,
|
|
87
|
+
private onClosing?: () => void,
|
|
88
|
+
private onClosed?: () => void,
|
|
89
|
+
) {}
|
|
90
|
+
|
|
91
|
+
public async connect(): Promise<void> {
|
|
92
|
+
if (this.connectStarted) {
|
|
93
|
+
throw new Error(`Codex connection ${this.connectionId} has already been started`);
|
|
94
|
+
}
|
|
95
|
+
this.connectStarted = true;
|
|
96
|
+
const headers: Record<string, string> = {};
|
|
97
|
+
const token = process.env.CODEX_REMOTE_TOKEN;
|
|
98
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
99
|
+
const socket = new plugins.WsWebSocket(this.serverUrl, {
|
|
100
|
+
perMessageDeflate: false,
|
|
101
|
+
headers,
|
|
102
|
+
followRedirects: false,
|
|
103
|
+
maxPayload: maxSocketMessageBytes,
|
|
104
|
+
});
|
|
105
|
+
this.socket = socket;
|
|
106
|
+
socket.on('message', (data) => this.handleMessage(data.toString()));
|
|
107
|
+
socket.on('error', (error) => this.handleSocketFailure(error));
|
|
108
|
+
socket.on('close', () => this.handleSocketClose());
|
|
109
|
+
await new Promise<void>((resolve, reject) => {
|
|
110
|
+
const cleanup = () => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
socket.off('open', handleOpen);
|
|
113
|
+
socket.off('error', handleError);
|
|
114
|
+
socket.off('close', handleClose);
|
|
115
|
+
};
|
|
116
|
+
const handleOpen = () => {
|
|
117
|
+
cleanup();
|
|
118
|
+
resolve();
|
|
119
|
+
};
|
|
120
|
+
const handleError = (errorArg: Error) => {
|
|
121
|
+
cleanup();
|
|
122
|
+
reject(errorArg);
|
|
123
|
+
};
|
|
124
|
+
const handleClose = () => {
|
|
125
|
+
cleanup();
|
|
126
|
+
reject(new Error('Codex server WebSocket closed during handshake'));
|
|
127
|
+
};
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
cleanup();
|
|
130
|
+
socket.terminate();
|
|
131
|
+
reject(new Error('Codex server WebSocket handshake timed out'));
|
|
132
|
+
}, 5000);
|
|
133
|
+
socket.once('open', handleOpen);
|
|
134
|
+
socket.once('error', handleError);
|
|
135
|
+
socket.once('close', handleClose);
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
const initialized = await this.request(
|
|
139
|
+
'initialize',
|
|
140
|
+
{
|
|
141
|
+
clientInfo: {
|
|
142
|
+
name: 'mcp-crossharness',
|
|
143
|
+
title: 'mcp-crossharness',
|
|
144
|
+
version: commitinfo.version,
|
|
145
|
+
},
|
|
146
|
+
capabilities: {
|
|
147
|
+
experimentalApi: false,
|
|
148
|
+
requestAttestation: false,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
5000,
|
|
152
|
+
true,
|
|
153
|
+
);
|
|
154
|
+
if (!helpers.isRecord(initialized) || typeof initialized.userAgent !== 'string') {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Codex server returned an invalid initialize response (expected app-server ${appServerVersion})`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
this.serverVersion = initialized.userAgent.match(/\/(\d+\.\d+\.\d+[^\s]*)/)?.[1];
|
|
160
|
+
if (this.state !== 'connecting' || socket.readyState !== plugins.WsWebSocket.OPEN) {
|
|
161
|
+
throw new Error('Codex server connection closed during initialization');
|
|
162
|
+
}
|
|
163
|
+
this.sendNotification('initialized');
|
|
164
|
+
this.state = 'connected';
|
|
165
|
+
} catch (error) {
|
|
166
|
+
socket.terminate();
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public getInfo(): interfaces.IConnectionInfo {
|
|
172
|
+
return {
|
|
173
|
+
connectionId: this.connectionId,
|
|
174
|
+
harness: this.harness,
|
|
175
|
+
serverUrl: this.serverUrl,
|
|
176
|
+
directory: this.directory,
|
|
177
|
+
state: this.state,
|
|
178
|
+
connectedAt: this.connectedAt,
|
|
179
|
+
serverVersion: this.serverVersion,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
public async getStatus(): Promise<interfaces.IConnectionStatus> {
|
|
184
|
+
if (this.state !== 'connected') {
|
|
185
|
+
return { ...this.getInfo(), healthy: false, detail: `connection is ${this.state}` };
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const response = await this.request('thread/loaded/list', {}, 5000);
|
|
189
|
+
const healthy = helpers.isRecord(response) && Array.isArray(response.data);
|
|
190
|
+
return {
|
|
191
|
+
...this.getInfo(),
|
|
192
|
+
healthy,
|
|
193
|
+
detail: healthy ? 'Codex app-server answered thread/loaded/list' : 'invalid health response',
|
|
194
|
+
};
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return { ...this.getInfo(), healthy: false, detail: helpers.errorMessage(error) };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
public async listChats(limitArg: number): Promise<interfaces.IChatSummary[]> {
|
|
201
|
+
const payload = await this.request('thread/list', {
|
|
202
|
+
limit: limitArg,
|
|
203
|
+
sortKey: 'updated_at',
|
|
204
|
+
sortDirection: 'desc',
|
|
205
|
+
archived: false,
|
|
206
|
+
cwd: this.directory,
|
|
207
|
+
});
|
|
208
|
+
if (!helpers.isRecord(payload) || !Array.isArray(payload.data)) {
|
|
209
|
+
throw new Error('Codex server returned an invalid thread list');
|
|
210
|
+
}
|
|
211
|
+
return payload.data.map((entry) => {
|
|
212
|
+
const thread = this.parseThread(entry);
|
|
213
|
+
this.assertThreadIdentity(thread, thread.id);
|
|
214
|
+
return {
|
|
215
|
+
harness: this.harness,
|
|
216
|
+
chatId: thread.id,
|
|
217
|
+
title: thread.name ?? thread.preview ?? '(untitled)',
|
|
218
|
+
directory: thread.cwd,
|
|
219
|
+
updatedAt: new Date(thread.updatedAt * 1000).toISOString(),
|
|
220
|
+
isActive: helpers.isRecord(thread.status) && thread.status.type === 'active',
|
|
221
|
+
meta: thread.modelProvider ? { modelProvider: thread.modelProvider } : undefined,
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public async readChat(
|
|
227
|
+
chatIdArg: string,
|
|
228
|
+
limitArg: number,
|
|
229
|
+
maxCharsArg: number,
|
|
230
|
+
): Promise<interfaces.IChatTranscript> {
|
|
231
|
+
this.assertChatId(chatIdArg);
|
|
232
|
+
const thread = await this.readThread(chatIdArg, true);
|
|
233
|
+
const messages: interfaces.IChatMessage[] = [];
|
|
234
|
+
for (const turn of thread.turns) {
|
|
235
|
+
if (!helpers.isRecord(turn) || !Array.isArray(turn.items)) continue;
|
|
236
|
+
for (const item of turn.items) {
|
|
237
|
+
if (!helpers.isRecord(item) || typeof item.type !== 'string') continue;
|
|
238
|
+
if (item.type === 'userMessage' && Array.isArray(item.content)) {
|
|
239
|
+
const text = item.content
|
|
240
|
+
.filter((content) => helpers.isRecord(content) && content.type === 'text')
|
|
241
|
+
.map((content) => String((content as Record<string, unknown>).text ?? ''))
|
|
242
|
+
.join('\n')
|
|
243
|
+
.trim();
|
|
244
|
+
if (text) messages.push({ role: 'user', text: helpers.truncateText(text, maxCharsArg) });
|
|
245
|
+
} else if (item.type === 'agentMessage' && typeof item.text === 'string') {
|
|
246
|
+
messages.push({ role: 'assistant', text: helpers.truncateText(item.text, maxCharsArg) });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const limited = messages.slice(-limitArg);
|
|
251
|
+
return {
|
|
252
|
+
harness: this.harness,
|
|
253
|
+
chatId: chatIdArg,
|
|
254
|
+
title: thread.name ?? thread.preview ?? '(untitled)',
|
|
255
|
+
directory: thread.cwd,
|
|
256
|
+
totalMessages: messages.length,
|
|
257
|
+
messages: limited,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public async sendMessage(
|
|
262
|
+
chatIdArg: string,
|
|
263
|
+
messageArg: string,
|
|
264
|
+
timeoutSecondsArg: number,
|
|
265
|
+
signalArg?: AbortSignal,
|
|
266
|
+
): Promise<interfaces.ISendResult> {
|
|
267
|
+
this.assertChatId(chatIdArg);
|
|
268
|
+
if (messageArg.length > maxTurnTextChars) {
|
|
269
|
+
throw new Error('Codex message exceeds the 1 MiB character limit');
|
|
270
|
+
}
|
|
271
|
+
const threadQueueSize = this.queuedSendsByThread.get(chatIdArg) ?? 0;
|
|
272
|
+
if (this.queuedSends >= maxQueuedSends || threadQueueSize >= maxQueuedSendsPerThread) {
|
|
273
|
+
throw new Error('Codex send queue is full');
|
|
274
|
+
}
|
|
275
|
+
this.queuedSends++;
|
|
276
|
+
this.queuedSendsByThread.set(chatIdArg, threadQueueSize + 1);
|
|
277
|
+
const deadline = Date.now() + timeoutSecondsArg * 1000;
|
|
278
|
+
try {
|
|
279
|
+
return await this.enqueueThreadSend(
|
|
280
|
+
chatIdArg,
|
|
281
|
+
deadline,
|
|
282
|
+
timeoutSecondsArg,
|
|
283
|
+
signalArg,
|
|
284
|
+
() =>
|
|
285
|
+
this.sendMessageInner(
|
|
286
|
+
chatIdArg,
|
|
287
|
+
messageArg,
|
|
288
|
+
deadline,
|
|
289
|
+
timeoutSecondsArg,
|
|
290
|
+
signalArg,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
} finally {
|
|
294
|
+
this.queuedSends--;
|
|
295
|
+
const remainingForThread = (this.queuedSendsByThread.get(chatIdArg) ?? 1) - 1;
|
|
296
|
+
if (remainingForThread > 0) this.queuedSendsByThread.set(chatIdArg, remainingForThread);
|
|
297
|
+
else this.queuedSendsByThread.delete(chatIdArg);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async sendMessageInner(
|
|
302
|
+
chatIdArg: string,
|
|
303
|
+
messageArg: string,
|
|
304
|
+
deadlineArg: number,
|
|
305
|
+
timeoutSecondsArg: number,
|
|
306
|
+
signalArg?: AbortSignal,
|
|
307
|
+
): Promise<interfaces.ISendResult> {
|
|
308
|
+
await this.readThread(
|
|
309
|
+
chatIdArg,
|
|
310
|
+
false,
|
|
311
|
+
this.remainingSendMs(deadlineArg, timeoutSecondsArg),
|
|
312
|
+
signalArg,
|
|
313
|
+
);
|
|
314
|
+
const resumed = await this.request(
|
|
315
|
+
'thread/resume',
|
|
316
|
+
{ threadId: chatIdArg },
|
|
317
|
+
this.remainingSendMs(deadlineArg, timeoutSecondsArg),
|
|
318
|
+
false,
|
|
319
|
+
signalArg,
|
|
320
|
+
);
|
|
321
|
+
if (
|
|
322
|
+
!helpers.isRecord(resumed) ||
|
|
323
|
+
!helpers.isRecord(resumed.thread) ||
|
|
324
|
+
typeof resumed.cwd !== 'string'
|
|
325
|
+
) {
|
|
326
|
+
throw new Error('Codex server returned an invalid thread/resume response');
|
|
327
|
+
}
|
|
328
|
+
const resumedThread = this.parseThread(resumed.thread);
|
|
329
|
+
this.assertThreadIdentity(resumedThread, chatIdArg);
|
|
330
|
+
if (resumed.cwd !== this.directory) {
|
|
331
|
+
throw new Error(`Codex thread ${chatIdArg} resumed outside the connection directory`);
|
|
332
|
+
}
|
|
333
|
+
const turnTimeoutMs = this.remainingSendMs(deadlineArg, timeoutSecondsArg);
|
|
334
|
+
if (signalArg?.aborted) throw this.abortError(signalArg.reason);
|
|
335
|
+
|
|
336
|
+
return new Promise<interfaces.ISendResult>((resolve, reject) => {
|
|
337
|
+
const turn: IPendingTurn = {
|
|
338
|
+
threadId: chatIdArg,
|
|
339
|
+
agentText: '',
|
|
340
|
+
deltaChunks: [],
|
|
341
|
+
deltaChars: 0,
|
|
342
|
+
earlyNotifications: new Map(),
|
|
343
|
+
earlyNotificationCount: 0,
|
|
344
|
+
earlyNotificationChars: 0,
|
|
345
|
+
resolve,
|
|
346
|
+
reject,
|
|
347
|
+
timer: setTimeout(
|
|
348
|
+
() => void this.timeoutTurn(turn, timeoutSecondsArg),
|
|
349
|
+
turnTimeoutMs,
|
|
350
|
+
),
|
|
351
|
+
};
|
|
352
|
+
this.pendingTurns.add(turn);
|
|
353
|
+
void this.request(
|
|
354
|
+
'turn/start',
|
|
355
|
+
{
|
|
356
|
+
threadId: chatIdArg,
|
|
357
|
+
clientUserMessageId: plugins.crypto.randomUUID(),
|
|
358
|
+
input: [{ type: 'text', text: messageArg, text_elements: [] }],
|
|
359
|
+
cwd: this.directory,
|
|
360
|
+
},
|
|
361
|
+
turnTimeoutMs,
|
|
362
|
+
)
|
|
363
|
+
.then((result) => {
|
|
364
|
+
if (!this.pendingTurns.has(turn)) return;
|
|
365
|
+
let turnId: string;
|
|
366
|
+
try {
|
|
367
|
+
turnId = this.extractTurnId(result);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
this.failUncertainTurnStart(turn, error);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
this.bindTurnId(turn, turnId);
|
|
373
|
+
})
|
|
374
|
+
.catch((error) => this.failUncertainTurnStart(turn, error));
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private enqueueThreadSend(
|
|
379
|
+
threadIdArg: string,
|
|
380
|
+
deadlineArg: number,
|
|
381
|
+
timeoutSecondsArg: number,
|
|
382
|
+
signalArg: AbortSignal | undefined,
|
|
383
|
+
taskArg: () => Promise<interfaces.ISendResult>,
|
|
384
|
+
): Promise<interfaces.ISendResult> {
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
const entry: IThreadQueueEntry = {
|
|
387
|
+
deadline: deadlineArg,
|
|
388
|
+
timeoutSeconds: timeoutSecondsArg,
|
|
389
|
+
task: taskArg,
|
|
390
|
+
resolve,
|
|
391
|
+
reject,
|
|
392
|
+
signal: signalArg,
|
|
393
|
+
};
|
|
394
|
+
if (signalArg?.aborted) {
|
|
395
|
+
reject(this.abortError(signalArg.reason));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const queue = this.threadQueues.get(threadIdArg) ?? [];
|
|
399
|
+
queue.push(entry);
|
|
400
|
+
this.threadQueues.set(threadIdArg, queue);
|
|
401
|
+
entry.timer = setTimeout(
|
|
402
|
+
() => this.timeoutQueuedSend(threadIdArg, entry),
|
|
403
|
+
Math.max(0, deadlineArg - Date.now()),
|
|
404
|
+
);
|
|
405
|
+
if (signalArg) {
|
|
406
|
+
entry.abortHandler = () => this.cancelThreadSend(threadIdArg, entry);
|
|
407
|
+
signalArg.addEventListener('abort', entry.abortHandler, { once: true });
|
|
408
|
+
}
|
|
409
|
+
this.drainThreadQueue(threadIdArg);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
public async close(reasonArg = 'connection disconnected'): Promise<void> {
|
|
414
|
+
if (this.state === 'closed') return;
|
|
415
|
+
if (this.state !== 'closing') {
|
|
416
|
+
this.state = 'closing';
|
|
417
|
+
this.rejectAll(new Error(reasonArg));
|
|
418
|
+
this.onClosing?.();
|
|
419
|
+
this.onClosing = undefined;
|
|
420
|
+
}
|
|
421
|
+
await this.closeSocket();
|
|
422
|
+
this.finishClosed();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private async readThread(
|
|
426
|
+
chatIdArg: string,
|
|
427
|
+
includeTurnsArg: boolean,
|
|
428
|
+
timeoutMsArg = 30000,
|
|
429
|
+
signalArg?: AbortSignal,
|
|
430
|
+
): Promise<ICodexThread> {
|
|
431
|
+
const payload = await this.request('thread/read', {
|
|
432
|
+
threadId: chatIdArg,
|
|
433
|
+
includeTurns: includeTurnsArg,
|
|
434
|
+
}, timeoutMsArg, false, signalArg);
|
|
435
|
+
if (!helpers.isRecord(payload) || !helpers.isRecord(payload.thread)) {
|
|
436
|
+
throw new Error('Codex server returned an invalid thread');
|
|
437
|
+
}
|
|
438
|
+
const thread = this.parseThread(payload.thread);
|
|
439
|
+
this.assertThreadIdentity(thread, chatIdArg);
|
|
440
|
+
return thread;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private parseThread(valueArg: unknown): ICodexThread {
|
|
444
|
+
if (
|
|
445
|
+
!helpers.isRecord(valueArg) ||
|
|
446
|
+
typeof valueArg.id !== 'string' ||
|
|
447
|
+
typeof valueArg.cwd !== 'string' ||
|
|
448
|
+
typeof valueArg.updatedAt !== 'number' ||
|
|
449
|
+
!Array.isArray(valueArg.turns)
|
|
450
|
+
) {
|
|
451
|
+
throw new Error('Codex server returned an invalid thread');
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
id: valueArg.id,
|
|
455
|
+
cwd: valueArg.cwd,
|
|
456
|
+
updatedAt: valueArg.updatedAt,
|
|
457
|
+
turns: valueArg.turns,
|
|
458
|
+
preview: typeof valueArg.preview === 'string' ? valueArg.preview : undefined,
|
|
459
|
+
name: typeof valueArg.name === 'string' || valueArg.name === null ? valueArg.name : undefined,
|
|
460
|
+
modelProvider:
|
|
461
|
+
typeof valueArg.modelProvider === 'string' ? valueArg.modelProvider : undefined,
|
|
462
|
+
status: valueArg.status,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private assertThreadIdentity(threadArg: ICodexThread, chatIdArg: string): void {
|
|
467
|
+
if (threadArg.id !== chatIdArg) {
|
|
468
|
+
throw new Error(`Codex server returned a different thread for ${chatIdArg}`);
|
|
469
|
+
}
|
|
470
|
+
if (threadArg.cwd !== this.directory) {
|
|
471
|
+
throw new Error(`Codex thread ${chatIdArg} is outside the connection directory`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private request(
|
|
476
|
+
methodArg: string,
|
|
477
|
+
paramsArg: unknown,
|
|
478
|
+
timeoutMsArg = 30000,
|
|
479
|
+
allowConnectingArg = false,
|
|
480
|
+
signalArg?: AbortSignal,
|
|
481
|
+
): Promise<unknown> {
|
|
482
|
+
if (!allowConnectingArg && this.state !== 'connected') {
|
|
483
|
+
return Promise.reject(new Error(`Codex connection ${this.connectionId} is ${this.state}`));
|
|
484
|
+
}
|
|
485
|
+
const socket = this.socket;
|
|
486
|
+
if (!socket || socket.readyState !== plugins.WsWebSocket.OPEN) {
|
|
487
|
+
return Promise.reject(new Error('Codex server WebSocket is not open'));
|
|
488
|
+
}
|
|
489
|
+
if (this.pendingRequests.size >= 256) {
|
|
490
|
+
return Promise.reject(new Error('Codex connection has too many active requests'));
|
|
491
|
+
}
|
|
492
|
+
if (signalArg?.aborted) return Promise.reject(this.abortError(signalArg.reason));
|
|
493
|
+
return new Promise((resolve, reject) => {
|
|
494
|
+
const id = ++this.requestCounter;
|
|
495
|
+
const timer = setTimeout(() => {
|
|
496
|
+
const pending = this.pendingRequests.get(id);
|
|
497
|
+
if (!pending) return;
|
|
498
|
+
this.pendingRequests.delete(id);
|
|
499
|
+
this.clearPendingRequestResources(pending);
|
|
500
|
+
reject(new Error(`Codex request timed out: ${methodArg}`));
|
|
501
|
+
}, timeoutMsArg);
|
|
502
|
+
const pending: IPendingRequest = { resolve, reject, timer, signal: signalArg };
|
|
503
|
+
if (signalArg) {
|
|
504
|
+
pending.abortHandler = () => {
|
|
505
|
+
if (!this.pendingRequests.delete(id)) return;
|
|
506
|
+
this.clearPendingRequestResources(pending);
|
|
507
|
+
reject(this.abortError(signalArg.reason));
|
|
508
|
+
};
|
|
509
|
+
signalArg.addEventListener('abort', pending.abortHandler, { once: true });
|
|
510
|
+
}
|
|
511
|
+
this.pendingRequests.set(id, pending);
|
|
512
|
+
const failSend = (errorArg: Error) => {
|
|
513
|
+
const current = this.pendingRequests.get(id);
|
|
514
|
+
if (!current) return;
|
|
515
|
+
this.pendingRequests.delete(id);
|
|
516
|
+
this.clearPendingRequestResources(current);
|
|
517
|
+
current.reject(errorArg);
|
|
518
|
+
};
|
|
519
|
+
try {
|
|
520
|
+
this.sendFrame({ id, method: methodArg, params: paramsArg });
|
|
521
|
+
} catch (error) {
|
|
522
|
+
failSend(error instanceof Error ? error : new Error(String(error)));
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private sendNotification(methodArg: string, paramsArg?: unknown): void {
|
|
528
|
+
this.sendFrame(
|
|
529
|
+
paramsArg === undefined
|
|
530
|
+
? { method: methodArg }
|
|
531
|
+
: { method: methodArg, params: paramsArg },
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private sendFrame(frameArg: Record<string, unknown>): void {
|
|
536
|
+
const socket = this.socket;
|
|
537
|
+
const payload = JSON.stringify(frameArg);
|
|
538
|
+
const payloadBytes = Buffer.byteLength(payload, 'utf8');
|
|
539
|
+
if (!socket || socket.readyState !== plugins.WsWebSocket.OPEN) {
|
|
540
|
+
const error = new Error('Codex server WebSocket is not open');
|
|
541
|
+
this.handleSocketFailure(error);
|
|
542
|
+
throw error;
|
|
543
|
+
}
|
|
544
|
+
if (socket.bufferedAmount + payloadBytes > maxSocketBufferedBytes) {
|
|
545
|
+
const error = new Error('Codex WebSocket send buffer exceeded the 4 MiB limit');
|
|
546
|
+
this.handleSocketFailure(error);
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
socket.send(payload, (error) => {
|
|
551
|
+
if (error) this.handleSocketFailure(error);
|
|
552
|
+
});
|
|
553
|
+
} catch (error) {
|
|
554
|
+
const sendError = error instanceof Error ? error : new Error(String(error));
|
|
555
|
+
this.handleSocketFailure(sendError);
|
|
556
|
+
throw sendError;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private handleMessage(rawArg: string): void {
|
|
561
|
+
const message = helpers.parseJson(rawArg);
|
|
562
|
+
if (!helpers.isRecord(message)) return;
|
|
563
|
+
if (message.id !== undefined && typeof message.method === 'string') {
|
|
564
|
+
this.respondToServerRequest(message);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (typeof message.id === 'number' && this.pendingRequests.has(message.id)) {
|
|
568
|
+
const pending = this.pendingRequests.get(message.id)!;
|
|
569
|
+
this.pendingRequests.delete(message.id);
|
|
570
|
+
this.clearPendingRequestResources(pending);
|
|
571
|
+
if (message.error !== undefined) {
|
|
572
|
+
pending.reject(
|
|
573
|
+
new Error(
|
|
574
|
+
`Codex server rejected request: ${JSON.stringify(message.error).slice(0, 4000)}`,
|
|
575
|
+
),
|
|
576
|
+
);
|
|
577
|
+
} else {
|
|
578
|
+
pending.resolve(message.result);
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (typeof message.method === 'string') {
|
|
583
|
+
if (!helpers.isRecord(message.params)) {
|
|
584
|
+
if (message.method === 'turn/completed') {
|
|
585
|
+
this.handleSocketFailure(new Error('Codex returned malformed turn/completed params'));
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
this.handleNotification(message.method, message.params);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private handleNotification(methodArg: string, paramsArg: Record<string, unknown>): void {
|
|
594
|
+
if (methodArg === 'turn/completed') {
|
|
595
|
+
if (
|
|
596
|
+
!helpers.isRecord(paramsArg.turn) ||
|
|
597
|
+
typeof paramsArg.turn.id !== 'string' ||
|
|
598
|
+
(paramsArg.turnId !== undefined && paramsArg.turnId !== paramsArg.turn.id)
|
|
599
|
+
) {
|
|
600
|
+
this.handleSocketFailure(new Error('Codex returned malformed turn/completed identity'));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const threadId = typeof paramsArg.threadId === 'string' ? paramsArg.threadId : undefined;
|
|
605
|
+
const turnId =
|
|
606
|
+
typeof paramsArg.turnId === 'string'
|
|
607
|
+
? paramsArg.turnId
|
|
608
|
+
: helpers.isRecord(paramsArg.turn) && typeof paramsArg.turn.id === 'string'
|
|
609
|
+
? paramsArg.turn.id
|
|
610
|
+
: undefined;
|
|
611
|
+
if (!threadId || !turnId) {
|
|
612
|
+
if (methodArg === 'turn/completed') {
|
|
613
|
+
this.handleSocketFailure(new Error('Codex returned malformed turn/completed identity'));
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const turn = [...this.pendingTurns].find(
|
|
618
|
+
(candidate) => candidate.threadId === threadId && candidate.turnId === turnId,
|
|
619
|
+
);
|
|
620
|
+
if (!turn) {
|
|
621
|
+
const unbound = [...this.pendingTurns].filter(
|
|
622
|
+
(candidate) => candidate.threadId === threadId && !candidate.turnId,
|
|
623
|
+
);
|
|
624
|
+
if (unbound.length === 1) this.bufferEarlyNotification(unbound[0], turnId, methodArg, paramsArg);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
this.handleTurnNotification(turn, methodArg, paramsArg);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private handleTurnNotification(
|
|
631
|
+
turnArg: IPendingTurn,
|
|
632
|
+
methodArg: string,
|
|
633
|
+
paramsArg: Record<string, unknown>,
|
|
634
|
+
): void {
|
|
635
|
+
if (methodArg === 'turn/completed' && !helpers.isRecord(paramsArg.turn)) {
|
|
636
|
+
this.handleSocketFailure(new Error('Codex returned malformed turn/completed payload'));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (methodArg === 'item/agentMessage/delta' && typeof paramsArg.delta === 'string') {
|
|
640
|
+
this.appendTurnDelta(turnArg, paramsArg.delta);
|
|
641
|
+
} else if (methodArg === 'item/completed' && helpers.isRecord(paramsArg.item)) {
|
|
642
|
+
if (paramsArg.item.type === 'agentMessage' && typeof paramsArg.item.text === 'string') {
|
|
643
|
+
if (paramsArg.item.text.length > maxTurnTextChars) {
|
|
644
|
+
this.handleSocketFailure(new Error('Codex turn output exceeded the response limit'));
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
turnArg.agentText = paramsArg.item.text;
|
|
648
|
+
}
|
|
649
|
+
} else if (methodArg === 'turn/completed' && helpers.isRecord(paramsArg.turn)) {
|
|
650
|
+
const status = paramsArg.turn.status;
|
|
651
|
+
if (turnArg.timeoutError) {
|
|
652
|
+
if (status !== 'completed' && status !== 'interrupted' && status !== 'failed') {
|
|
653
|
+
this.handleSocketFailure(
|
|
654
|
+
new Error('Codex returned a non-terminal status in turn/completed'),
|
|
655
|
+
);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
turnArg.terminalObserved = true;
|
|
659
|
+
this.rejectTimedOutTurnIfSafe(turnArg);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (status === 'completed') {
|
|
663
|
+
this.completeTurn(turnArg);
|
|
664
|
+
} else if (status === 'interrupted' || status === 'failed') {
|
|
665
|
+
const turnError = helpers.isRecord(paramsArg.turn.error)
|
|
666
|
+
? String(paramsArg.turn.error.message ?? status)
|
|
667
|
+
: String(status);
|
|
668
|
+
this.failTurn(turnArg, new Error(`Codex turn ${status}: ${turnError}`));
|
|
669
|
+
} else {
|
|
670
|
+
this.handleSocketFailure(
|
|
671
|
+
new Error('Codex returned a non-terminal status in turn/completed'),
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private bindTurnId(turnArg: IPendingTurn, turnIdArg: string): void {
|
|
678
|
+
if (!this.pendingTurns.has(turnArg)) return;
|
|
679
|
+
if (turnArg.turnId && turnArg.turnId !== turnIdArg) {
|
|
680
|
+
this.failTurn(turnArg, new Error('Codex turn identity changed during dispatch'));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const collision = [...this.pendingTurns].find(
|
|
684
|
+
(candidate) => candidate !== turnArg && candidate.turnId === turnIdArg,
|
|
685
|
+
);
|
|
686
|
+
if (collision) {
|
|
687
|
+
this.handleSocketFailure(new Error('Codex reused a turn id across concurrent sends'));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
turnArg.turnId = turnIdArg;
|
|
691
|
+
const buffered = turnArg.earlyNotifications.get(turnIdArg) ?? [];
|
|
692
|
+
turnArg.earlyNotifications.clear();
|
|
693
|
+
turnArg.earlyNotificationCount = 0;
|
|
694
|
+
turnArg.earlyNotificationChars = 0;
|
|
695
|
+
for (const notification of buffered) {
|
|
696
|
+
if (!this.pendingTurns.has(turnArg)) break;
|
|
697
|
+
this.handleTurnNotification(turnArg, notification.method, notification.params);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private extractTurnId(valueArg: unknown): string {
|
|
702
|
+
if (!helpers.isRecord(valueArg) || !helpers.isRecord(valueArg.turn) || typeof valueArg.turn.id !== 'string') {
|
|
703
|
+
throw new Error('Codex server returned an invalid turn/start response');
|
|
704
|
+
}
|
|
705
|
+
return valueArg.turn.id;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private completeTurn(turnArg: IPendingTurn): void {
|
|
709
|
+
if (!this.settleTurn(turnArg)) return;
|
|
710
|
+
const reply = (turnArg.agentText || turnArg.deltaChunks.join('')).trim();
|
|
711
|
+
if (!reply) {
|
|
712
|
+
turnArg.reject(new Error('Codex turn completed without assistant text'));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
turnArg.resolve({
|
|
716
|
+
harness: this.harness,
|
|
717
|
+
chatId: turnArg.threadId,
|
|
718
|
+
reply,
|
|
719
|
+
via: `ws:${this.serverUrl}`,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private failTurn(turnArg: IPendingTurn, errorArg: unknown): void {
|
|
724
|
+
if (!this.settleTurn(turnArg)) return;
|
|
725
|
+
turnArg.reject(errorArg instanceof Error ? errorArg : new Error(String(errorArg)));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private async timeoutTurn(turnArg: IPendingTurn, timeoutSecondsArg: number): Promise<void> {
|
|
729
|
+
if (!this.pendingTurns.has(turnArg) || turnArg.timeoutError) return;
|
|
730
|
+
const timeoutError = new Error(
|
|
731
|
+
`Codex turn timed out after ${timeoutSecondsArg}s; Crossharness did not retry`,
|
|
732
|
+
);
|
|
733
|
+
turnArg.timeoutError = timeoutError;
|
|
734
|
+
const turnId = turnArg.turnId;
|
|
735
|
+
if (!turnId) {
|
|
736
|
+
const fatalError = new Error(
|
|
737
|
+
`${timeoutError.message}; no turn id was available to interrupt safely, so the connection was closed`,
|
|
738
|
+
);
|
|
739
|
+
this.handleSocketFailure(fatalError);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
await this.request('turn/interrupt', { threadId: turnArg.threadId, turnId }, 3000);
|
|
744
|
+
if (!this.pendingTurns.has(turnArg)) return;
|
|
745
|
+
turnArg.interruptAcknowledged = true;
|
|
746
|
+
if (turnArg.terminalObserved) {
|
|
747
|
+
this.rejectTimedOutTurnIfSafe(turnArg);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
turnArg.terminalTimer = setTimeout(() => {
|
|
751
|
+
if (!this.pendingTurns.has(turnArg)) return;
|
|
752
|
+
this.handleSocketFailure(
|
|
753
|
+
new Error(
|
|
754
|
+
`${timeoutError.message}; the interrupted turn did not report terminal completion, so the connection was closed`,
|
|
755
|
+
),
|
|
756
|
+
);
|
|
757
|
+
}, 3000);
|
|
758
|
+
turnArg.terminalTimer.unref();
|
|
759
|
+
} catch (error) {
|
|
760
|
+
const fatalError = new Error(
|
|
761
|
+
`${timeoutError.message}; turn/interrupt was not acknowledged, so the connection was closed: ${helpers.errorMessage(error)}`,
|
|
762
|
+
{ cause: error },
|
|
763
|
+
);
|
|
764
|
+
this.handleSocketFailure(fatalError);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private async cancelActiveTurn(turnArg: IPendingTurn, errorArg: Error): Promise<void> {
|
|
769
|
+
if (!this.pendingTurns.has(turnArg) || turnArg.timeoutError) return;
|
|
770
|
+
turnArg.timeoutError = errorArg;
|
|
771
|
+
const turnId = turnArg.turnId;
|
|
772
|
+
if (!turnId) {
|
|
773
|
+
this.handleSocketFailure(
|
|
774
|
+
new Error(
|
|
775
|
+
`${errorArg.message}; no turn id was available to interrupt safely, so the connection was closed`,
|
|
776
|
+
),
|
|
777
|
+
);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
await this.request('turn/interrupt', { threadId: turnArg.threadId, turnId }, 3000);
|
|
782
|
+
if (!this.pendingTurns.has(turnArg)) return;
|
|
783
|
+
turnArg.interruptAcknowledged = true;
|
|
784
|
+
if (turnArg.terminalObserved) {
|
|
785
|
+
this.rejectTimedOutTurnIfSafe(turnArg);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
turnArg.terminalTimer = setTimeout(() => {
|
|
789
|
+
if (!this.pendingTurns.has(turnArg)) return;
|
|
790
|
+
this.handleSocketFailure(
|
|
791
|
+
new Error(
|
|
792
|
+
`${errorArg.message}; the interrupted turn did not report terminal completion, so the connection was closed`,
|
|
793
|
+
),
|
|
794
|
+
);
|
|
795
|
+
}, 3000);
|
|
796
|
+
turnArg.terminalTimer.unref();
|
|
797
|
+
} catch (error) {
|
|
798
|
+
this.handleSocketFailure(
|
|
799
|
+
new Error(
|
|
800
|
+
`${errorArg.message}; turn/interrupt was not acknowledged, so the connection was closed: ${helpers.errorMessage(error)}`,
|
|
801
|
+
{ cause: error },
|
|
802
|
+
),
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private timeoutQueuedSend(threadIdArg: string, entryArg: IThreadQueueEntry): void {
|
|
808
|
+
if (this.activeThreads.has(threadIdArg)) {
|
|
809
|
+
const queue = this.threadQueues.get(threadIdArg);
|
|
810
|
+
if (queue?.[0] === entryArg) return;
|
|
811
|
+
}
|
|
812
|
+
const queue = this.threadQueues.get(threadIdArg);
|
|
813
|
+
if (!queue) return;
|
|
814
|
+
const index = queue.indexOf(entryArg);
|
|
815
|
+
if (index < 0) return;
|
|
816
|
+
queue.splice(index, 1);
|
|
817
|
+
this.clearQueueEntryResources(entryArg);
|
|
818
|
+
entryArg.reject(
|
|
819
|
+
new Error(
|
|
820
|
+
`Codex send timed out after ${entryArg.timeoutSeconds}s while queued; no message was delivered`,
|
|
821
|
+
),
|
|
822
|
+
);
|
|
823
|
+
if (queue.length === 0 && !this.activeThreads.has(threadIdArg)) {
|
|
824
|
+
this.threadQueues.delete(threadIdArg);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private cancelThreadSend(threadIdArg: string, entryArg: IThreadQueueEntry): void {
|
|
829
|
+
const queue = this.threadQueues.get(threadIdArg);
|
|
830
|
+
if (!queue) return;
|
|
831
|
+
const index = queue.indexOf(entryArg);
|
|
832
|
+
if (index < 0) return;
|
|
833
|
+
if (index === 0 && this.activeThreads.has(threadIdArg)) {
|
|
834
|
+
const turn = [...this.pendingTurns].find((candidate) => candidate.threadId === threadIdArg);
|
|
835
|
+
if (turn) void this.cancelActiveTurn(turn, this.abortError(entryArg.signal?.reason));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
queue.splice(index, 1);
|
|
839
|
+
this.clearQueueEntryResources(entryArg);
|
|
840
|
+
entryArg.reject(this.abortError(entryArg.signal?.reason));
|
|
841
|
+
if (queue.length === 0 && !this.activeThreads.has(threadIdArg)) {
|
|
842
|
+
this.threadQueues.delete(threadIdArg);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private drainThreadQueue(threadIdArg: string): void {
|
|
847
|
+
if (this.activeThreads.has(threadIdArg)) return;
|
|
848
|
+
const queue = this.threadQueues.get(threadIdArg);
|
|
849
|
+
const entry = queue?.[0];
|
|
850
|
+
if (!queue || !entry) {
|
|
851
|
+
this.threadQueues.delete(threadIdArg);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (entry.deadline <= Date.now()) {
|
|
855
|
+
this.timeoutQueuedSend(threadIdArg, entry);
|
|
856
|
+
this.drainThreadQueue(threadIdArg);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
this.activeThreads.add(threadIdArg);
|
|
860
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
861
|
+
void entry.task().then(entry.resolve, entry.reject).finally(() => {
|
|
862
|
+
this.clearQueueEntryResources(entry);
|
|
863
|
+
this.activeThreads.delete(threadIdArg);
|
|
864
|
+
const currentQueue = this.threadQueues.get(threadIdArg);
|
|
865
|
+
if (currentQueue?.[0] === entry) currentQueue.shift();
|
|
866
|
+
if (currentQueue?.length === 0) this.threadQueues.delete(threadIdArg);
|
|
867
|
+
this.drainThreadQueue(threadIdArg);
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private appendTurnDelta(turnArg: IPendingTurn, deltaArg: string): void {
|
|
872
|
+
if (turnArg.deltaChunks.length >= maxTurnDeltaChunks) {
|
|
873
|
+
this.handleSocketFailure(new Error('Codex turn output exceeded the delta chunk limit'));
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (turnArg.deltaChars + deltaArg.length > maxTurnTextChars) {
|
|
877
|
+
this.handleSocketFailure(new Error('Codex turn output exceeded the response limit'));
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
turnArg.deltaChunks.push(deltaArg);
|
|
881
|
+
turnArg.deltaChars += deltaArg.length;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
private remainingSendMs(deadlineArg: number, timeoutSecondsArg: number): number {
|
|
885
|
+
const remainingMs = deadlineArg - Date.now();
|
|
886
|
+
if (remainingMs <= 0) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
`Codex send timed out after ${timeoutSecondsArg}s before turn/start; no message was delivered`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
return remainingMs;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private clearQueueEntryResources(entryArg: IThreadQueueEntry): void {
|
|
895
|
+
if (entryArg.timer) clearTimeout(entryArg.timer);
|
|
896
|
+
if (entryArg.abortHandler) {
|
|
897
|
+
entryArg.signal?.removeEventListener('abort', entryArg.abortHandler);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private clearPendingRequestResources(pendingArg: IPendingRequest): void {
|
|
902
|
+
clearTimeout(pendingArg.timer);
|
|
903
|
+
if (pendingArg.abortHandler) {
|
|
904
|
+
pendingArg.signal?.removeEventListener('abort', pendingArg.abortHandler);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private abortError(reasonArg: unknown): Error {
|
|
909
|
+
return reasonArg instanceof Error ? reasonArg : new Error('operation cancelled');
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private assertChatId(chatIdArg: string): void {
|
|
913
|
+
if (!chatIdArg || chatIdArg.length > 1024) {
|
|
914
|
+
throw new Error('Codex chatId must contain 1-1024 characters');
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
private bufferEarlyNotification(
|
|
919
|
+
turnArg: IPendingTurn,
|
|
920
|
+
turnIdArg: string,
|
|
921
|
+
methodArg: string,
|
|
922
|
+
paramsArg: Record<string, unknown>,
|
|
923
|
+
): void {
|
|
924
|
+
const notification = this.normalizeEarlyNotification(methodArg, paramsArg);
|
|
925
|
+
if (!notification) return;
|
|
926
|
+
turnArg.earlyNotificationCount++;
|
|
927
|
+
if (turnArg.earlyNotificationCount > maxEarlyNotifications) {
|
|
928
|
+
this.handleSocketFailure(
|
|
929
|
+
new Error('Codex sent too many turn notifications before acknowledging turn/start'),
|
|
930
|
+
);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const notificationChars = JSON.stringify(notification).length;
|
|
934
|
+
turnArg.earlyNotificationChars += notificationChars;
|
|
935
|
+
if (turnArg.earlyNotificationChars > maxTurnTextChars) {
|
|
936
|
+
this.handleSocketFailure(
|
|
937
|
+
new Error('Codex buffered turn notifications exceeded the response limit'),
|
|
938
|
+
);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const notifications = turnArg.earlyNotifications.get(turnIdArg) ?? [];
|
|
942
|
+
notifications.push(notification);
|
|
943
|
+
turnArg.earlyNotifications.set(turnIdArg, notifications);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private normalizeEarlyNotification(
|
|
947
|
+
methodArg: string,
|
|
948
|
+
paramsArg: Record<string, unknown>,
|
|
949
|
+
): ICodexNotification | undefined {
|
|
950
|
+
if (methodArg === 'item/agentMessage/delta' && typeof paramsArg.delta === 'string') {
|
|
951
|
+
return { method: methodArg, params: { delta: paramsArg.delta } };
|
|
952
|
+
}
|
|
953
|
+
if (methodArg === 'item/completed' && helpers.isRecord(paramsArg.item)) {
|
|
954
|
+
const item = paramsArg.item;
|
|
955
|
+
if (item.type === 'agentMessage' && typeof item.text === 'string') {
|
|
956
|
+
return { method: methodArg, params: { item: { type: item.type, text: item.text } } };
|
|
957
|
+
}
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
if (methodArg === 'turn/completed' && helpers.isRecord(paramsArg.turn)) {
|
|
961
|
+
const turn = paramsArg.turn;
|
|
962
|
+
return {
|
|
963
|
+
method: methodArg,
|
|
964
|
+
params: {
|
|
965
|
+
turn: {
|
|
966
|
+
status: turn.status,
|
|
967
|
+
error: helpers.isRecord(turn.error) ? { message: turn.error.message } : turn.error,
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
return undefined;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private failUncertainTurnStart(turnArg: IPendingTurn, errorArg: unknown): void {
|
|
976
|
+
if (!this.pendingTurns.has(turnArg)) return;
|
|
977
|
+
const fatalError = new Error(
|
|
978
|
+
`Codex turn/start outcome is uncertain, so the connection was closed: ${helpers.errorMessage(errorArg)}`,
|
|
979
|
+
{ cause: errorArg },
|
|
980
|
+
);
|
|
981
|
+
this.handleSocketFailure(fatalError);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
private rejectTimedOutTurnIfSafe(turnArg: IPendingTurn): void {
|
|
985
|
+
const error = turnArg.timeoutError;
|
|
986
|
+
if (
|
|
987
|
+
!error ||
|
|
988
|
+
!turnArg.interruptAcknowledged ||
|
|
989
|
+
!turnArg.terminalObserved ||
|
|
990
|
+
!this.settleTurn(turnArg)
|
|
991
|
+
) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
turnArg.reject(error);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
private settleTurn(turnArg: IPendingTurn): boolean {
|
|
998
|
+
if (!this.pendingTurns.delete(turnArg)) return false;
|
|
999
|
+
clearTimeout(turnArg.timer);
|
|
1000
|
+
if (turnArg.terminalTimer) clearTimeout(turnArg.terminalTimer);
|
|
1001
|
+
turnArg.earlyNotifications.clear();
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
private respondToServerRequest(messageArg: Record<string, unknown>): void {
|
|
1006
|
+
const method = String(messageArg.method);
|
|
1007
|
+
let result: unknown;
|
|
1008
|
+
switch (method) {
|
|
1009
|
+
case 'item/commandExecution/requestApproval':
|
|
1010
|
+
case 'item/fileChange/requestApproval':
|
|
1011
|
+
result = { decision: 'decline' };
|
|
1012
|
+
break;
|
|
1013
|
+
case 'item/tool/requestUserInput':
|
|
1014
|
+
result = { answers: {} };
|
|
1015
|
+
break;
|
|
1016
|
+
case 'mcpServer/elicitation/request':
|
|
1017
|
+
result = { action: 'decline', content: null, _meta: null };
|
|
1018
|
+
break;
|
|
1019
|
+
case 'item/permissions/requestApproval':
|
|
1020
|
+
result = { permissions: {}, scope: 'turn' };
|
|
1021
|
+
break;
|
|
1022
|
+
case 'applyPatchApproval':
|
|
1023
|
+
case 'execCommandApproval':
|
|
1024
|
+
result = { decision: 'abort' };
|
|
1025
|
+
break;
|
|
1026
|
+
default:
|
|
1027
|
+
try {
|
|
1028
|
+
this.sendFrame({
|
|
1029
|
+
id: messageArg.id,
|
|
1030
|
+
error: { code: -32601, message: `Crossharness does not implement ${method}` },
|
|
1031
|
+
});
|
|
1032
|
+
} catch {}
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
try {
|
|
1036
|
+
this.sendFrame({ id: messageArg.id, result });
|
|
1037
|
+
} catch {}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private handleSocketFailure(errorArg: Error): void {
|
|
1041
|
+
if (this.state === 'closed') return;
|
|
1042
|
+
if (this.state !== 'closing') {
|
|
1043
|
+
this.state = 'closing';
|
|
1044
|
+
this.rejectAll(errorArg);
|
|
1045
|
+
this.onClosing?.();
|
|
1046
|
+
this.onClosing = undefined;
|
|
1047
|
+
}
|
|
1048
|
+
const socket = this.socket;
|
|
1049
|
+
if (socket && socket.readyState !== plugins.WsWebSocket.CLOSED) {
|
|
1050
|
+
socket.terminate();
|
|
1051
|
+
} else {
|
|
1052
|
+
this.finishClosed();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private handleSocketClose(): void {
|
|
1057
|
+
if (this.state !== 'closing' && this.state !== 'closed') {
|
|
1058
|
+
this.state = 'closing';
|
|
1059
|
+
this.rejectAll(new Error('Codex server connection closed'));
|
|
1060
|
+
this.onClosing?.();
|
|
1061
|
+
this.onClosing = undefined;
|
|
1062
|
+
}
|
|
1063
|
+
this.finishClosed();
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
private finishClosed(): void {
|
|
1067
|
+
if (this.state === 'closed') return;
|
|
1068
|
+
this.state = 'closed';
|
|
1069
|
+
this.onClosed?.();
|
|
1070
|
+
this.onClosing = undefined;
|
|
1071
|
+
this.onClosed = undefined;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
private rejectAll(errorArg: Error): void {
|
|
1075
|
+
for (const pending of this.pendingRequests.values()) {
|
|
1076
|
+
this.clearPendingRequestResources(pending);
|
|
1077
|
+
pending.reject(errorArg);
|
|
1078
|
+
}
|
|
1079
|
+
this.pendingRequests.clear();
|
|
1080
|
+
for (const turn of [...this.pendingTurns]) this.failTurn(turn, errorArg);
|
|
1081
|
+
for (const [threadId, queue] of this.threadQueues) {
|
|
1082
|
+
const activeOffset = this.activeThreads.has(threadId) ? 1 : 0;
|
|
1083
|
+
for (const entry of queue.slice(activeOffset)) {
|
|
1084
|
+
this.clearQueueEntryResources(entry);
|
|
1085
|
+
entry.reject(errorArg);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
this.threadQueues.clear();
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
private async closeSocket(): Promise<void> {
|
|
1092
|
+
const socket = this.socket;
|
|
1093
|
+
if (!socket || socket.readyState === plugins.WsWebSocket.CLOSED) return;
|
|
1094
|
+
await new Promise<void>((resolve) => {
|
|
1095
|
+
let settled = false;
|
|
1096
|
+
const finish = () => {
|
|
1097
|
+
if (settled) return;
|
|
1098
|
+
settled = true;
|
|
1099
|
+
clearTimeout(timer);
|
|
1100
|
+
socket.off('close', finish);
|
|
1101
|
+
resolve();
|
|
1102
|
+
};
|
|
1103
|
+
const timer = setTimeout(() => {
|
|
1104
|
+
socket.terminate();
|
|
1105
|
+
finish();
|
|
1106
|
+
}, 1000);
|
|
1107
|
+
socket.once('close', finish);
|
|
1108
|
+
try {
|
|
1109
|
+
if (socket.readyState === plugins.WsWebSocket.CONNECTING) {
|
|
1110
|
+
socket.terminate();
|
|
1111
|
+
} else {
|
|
1112
|
+
socket.close();
|
|
1113
|
+
}
|
|
1114
|
+
} catch {
|
|
1115
|
+
socket.terminate();
|
|
1116
|
+
finish();
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|