dev-sessions 0.1.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/README.md +156 -0
- package/dist/backends/claude-tmux.d.ts +19 -0
- package/dist/backends/claude-tmux.js +162 -0
- package/dist/backends/claude-tmux.js.map +1 -0
- package/dist/backends/codex-appserver.d.ts +71 -0
- package/dist/backends/codex-appserver.js +839 -0
- package/dist/backends/codex-appserver.js.map +1 -0
- package/dist/champion-ids.d.ts +11 -0
- package/dist/champion-ids.js +51 -0
- package/dist/champion-ids.js.map +1 -0
- package/dist/cli.d.ts +33 -0
- package/dist/cli.js +307 -0
- package/dist/cli.js.map +1 -0
- package/dist/gateway/client.d.ts +31 -0
- package/dist/gateway/client.js +146 -0
- package/dist/gateway/client.js.map +1 -0
- package/dist/gateway/server.d.ts +27 -0
- package/dist/gateway/server.js +409 -0
- package/dist/gateway/server.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/session-manager.d.ts +36 -0
- package/dist/session-manager.js +407 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/session-store.d.ts +15 -0
- package/dist/session-store.js +143 -0
- package/dist/session-store.js.map +1 -0
- package/dist/transcript/claude-parser.d.ts +17 -0
- package/dist/transcript/claude-parser.js +203 -0
- package/dist/transcript/claude-parser.js.map +1 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
- package/skill/SKILL.md +141 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.CodexAppServerBackend = void 0;
|
|
7
|
+
const node_child_process_1 = require("node:child_process");
|
|
8
|
+
const promises_1 = require("node:fs/promises");
|
|
9
|
+
const node_net_1 = require("node:net");
|
|
10
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const ws_1 = __importDefault(require("ws"));
|
|
13
|
+
const DEFAULT_MODEL = 'gpt-5.3-codex';
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
16
|
+
const STARTUP_TIMEOUT_MS = 15_000;
|
|
17
|
+
const PORT_POLL_INTERVAL_MS = 100;
|
|
18
|
+
const CLOSE_TIMEOUT_MS = 500;
|
|
19
|
+
const STATE_FILE_VERSION = 1;
|
|
20
|
+
const RESUME_NOT_FOUND_PATTERN = /no rollout found|thread not found/i;
|
|
21
|
+
const APP_SERVER_URL_PATTERN = /ws:\/\/127\.0\.0\.1:(\d+)/i;
|
|
22
|
+
function defaultSpawnCodexDaemon(args, options) {
|
|
23
|
+
return (0, node_child_process_1.spawn)('codex', args, options);
|
|
24
|
+
}
|
|
25
|
+
function getDaemonStateFilePath() {
|
|
26
|
+
return node_path_1.default.join(node_os_1.default.homedir(), '.dev-sessions', 'codex-appserver.json');
|
|
27
|
+
}
|
|
28
|
+
function getDaemonLogFilePath() {
|
|
29
|
+
return node_path_1.default.join(node_os_1.default.homedir(), '.dev-sessions', 'codex-appserver.log');
|
|
30
|
+
}
|
|
31
|
+
function isProcessRunning(pid) {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'EPERM') {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function sleep(ms) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
setTimeout(resolve, ms);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function waitForPortOpen(port, timeoutMs) {
|
|
49
|
+
const deadline = Date.now() + timeoutMs;
|
|
50
|
+
while (Date.now() <= deadline) {
|
|
51
|
+
try {
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
const socket = (0, node_net_1.createConnection)({
|
|
54
|
+
host: '127.0.0.1',
|
|
55
|
+
port
|
|
56
|
+
});
|
|
57
|
+
socket.once('connect', () => {
|
|
58
|
+
socket.destroy();
|
|
59
|
+
resolve();
|
|
60
|
+
});
|
|
61
|
+
socket.once('error', (error) => {
|
|
62
|
+
socket.destroy();
|
|
63
|
+
reject(error);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
await sleep(PORT_POLL_INTERVAL_MS);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Timed out waiting for Codex app-server to listen on port ${port}`);
|
|
73
|
+
}
|
|
74
|
+
class DefaultCodexAppServerDaemonManager {
|
|
75
|
+
stateFilePath;
|
|
76
|
+
logFilePath;
|
|
77
|
+
spawnDaemonProcess;
|
|
78
|
+
constructor(stateFilePath = getDaemonStateFilePath(), logFilePath = getDaemonLogFilePath(), spawnDaemonProcess = defaultSpawnCodexDaemon) {
|
|
79
|
+
this.stateFilePath = stateFilePath;
|
|
80
|
+
this.logFilePath = logFilePath;
|
|
81
|
+
this.spawnDaemonProcess = spawnDaemonProcess;
|
|
82
|
+
}
|
|
83
|
+
async ensureServer() {
|
|
84
|
+
const existing = await this.getServer();
|
|
85
|
+
if (existing) {
|
|
86
|
+
return existing;
|
|
87
|
+
}
|
|
88
|
+
return this.startServer();
|
|
89
|
+
}
|
|
90
|
+
async getServer() {
|
|
91
|
+
const state = await this.readState();
|
|
92
|
+
if (!state) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
if (!isProcessRunning(state.pid)) {
|
|
96
|
+
await this.deleteStateFile();
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
pid: state.pid,
|
|
101
|
+
port: state.port,
|
|
102
|
+
url: state.url
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async isServerRunning(pid, _port) {
|
|
106
|
+
if (typeof pid === 'number') {
|
|
107
|
+
return isProcessRunning(pid);
|
|
108
|
+
}
|
|
109
|
+
return (await this.getServer()) !== undefined;
|
|
110
|
+
}
|
|
111
|
+
async resetServer(server) {
|
|
112
|
+
const target = server ?? (await this.getServer());
|
|
113
|
+
if (!target) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
process.kill(target.pid, 'SIGTERM');
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error.code !== 'ESRCH') {
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await this.deleteStateFile();
|
|
125
|
+
}
|
|
126
|
+
async stopServer() {
|
|
127
|
+
await this.resetServer();
|
|
128
|
+
}
|
|
129
|
+
async startServer() {
|
|
130
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(this.stateFilePath), { recursive: true });
|
|
131
|
+
const logHandle = await (0, promises_1.open)(this.logFilePath, 'w');
|
|
132
|
+
let child;
|
|
133
|
+
try {
|
|
134
|
+
child = this.spawnDaemonProcess(['app-server', '--listen', 'ws://127.0.0.1:0'], {
|
|
135
|
+
detached: true,
|
|
136
|
+
stdio: ['ignore', logHandle.fd, logHandle.fd]
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await logHandle.close();
|
|
141
|
+
}
|
|
142
|
+
if (!child.pid || !Number.isInteger(child.pid)) {
|
|
143
|
+
throw new Error('Failed to start Codex app-server (missing PID)');
|
|
144
|
+
}
|
|
145
|
+
child.unref();
|
|
146
|
+
const port = await this.waitForListeningPort(child.pid);
|
|
147
|
+
await waitForPortOpen(port, STARTUP_TIMEOUT_MS);
|
|
148
|
+
const info = {
|
|
149
|
+
pid: child.pid,
|
|
150
|
+
port,
|
|
151
|
+
url: `ws://127.0.0.1:${port}`
|
|
152
|
+
};
|
|
153
|
+
await this.writeState(info);
|
|
154
|
+
return info;
|
|
155
|
+
}
|
|
156
|
+
async waitForListeningPort(pid) {
|
|
157
|
+
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
|
158
|
+
while (Date.now() <= deadline) {
|
|
159
|
+
const logContents = await this.readLog();
|
|
160
|
+
const match = logContents.match(APP_SERVER_URL_PATTERN);
|
|
161
|
+
if (match) {
|
|
162
|
+
const port = Number.parseInt(match[1], 10);
|
|
163
|
+
if (Number.isInteger(port) && port > 0) {
|
|
164
|
+
return port;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!isProcessRunning(pid)) {
|
|
168
|
+
throw new Error(`Codex app-server exited during startup. Log output:\n${logContents.trim()}`);
|
|
169
|
+
}
|
|
170
|
+
await sleep(PORT_POLL_INTERVAL_MS);
|
|
171
|
+
}
|
|
172
|
+
const finalLog = await this.readLog();
|
|
173
|
+
throw new Error(`Timed out waiting for Codex app-server startup. Log output:\n${finalLog.trim()}`);
|
|
174
|
+
}
|
|
175
|
+
async readLog() {
|
|
176
|
+
try {
|
|
177
|
+
return await (0, promises_1.readFile)(this.logFilePath, 'utf8');
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error.code === 'ENOENT') {
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async readState() {
|
|
187
|
+
try {
|
|
188
|
+
const raw = await (0, promises_1.readFile)(this.stateFilePath, 'utf8');
|
|
189
|
+
const parsed = JSON.parse(raw);
|
|
190
|
+
if (parsed.version !== STATE_FILE_VERSION ||
|
|
191
|
+
!Number.isInteger(parsed.pid) ||
|
|
192
|
+
!Number.isInteger(parsed.port) ||
|
|
193
|
+
typeof parsed.url !== 'string') {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
version: STATE_FILE_VERSION,
|
|
198
|
+
pid: parsed.pid,
|
|
199
|
+
port: parsed.port,
|
|
200
|
+
url: parsed.url,
|
|
201
|
+
startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : new Date().toISOString()
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error.code === 'ENOENT') {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async writeState(info) {
|
|
212
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(this.stateFilePath), { recursive: true });
|
|
213
|
+
const tmpPath = `${this.stateFilePath}.tmp`;
|
|
214
|
+
const payload = {
|
|
215
|
+
version: STATE_FILE_VERSION,
|
|
216
|
+
pid: info.pid,
|
|
217
|
+
port: info.port,
|
|
218
|
+
url: info.url,
|
|
219
|
+
startedAt: new Date().toISOString()
|
|
220
|
+
};
|
|
221
|
+
await (0, promises_1.writeFile)(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
222
|
+
await (0, promises_1.rename)(tmpPath, this.stateFilePath);
|
|
223
|
+
}
|
|
224
|
+
async deleteStateFile() {
|
|
225
|
+
await (0, promises_1.rm)(this.stateFilePath, { force: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
class CodexWebSocketRpcClient {
|
|
229
|
+
url;
|
|
230
|
+
ws;
|
|
231
|
+
connectPromise;
|
|
232
|
+
nextRequestId = 1;
|
|
233
|
+
stdoutBuffer = '';
|
|
234
|
+
pendingRequests = new Map();
|
|
235
|
+
waiters = [];
|
|
236
|
+
currentText = '';
|
|
237
|
+
turnStatus;
|
|
238
|
+
turnError;
|
|
239
|
+
closed = false;
|
|
240
|
+
closing = false;
|
|
241
|
+
constructor(url) {
|
|
242
|
+
this.url = url;
|
|
243
|
+
}
|
|
244
|
+
get currentTurnText() {
|
|
245
|
+
return this.currentText;
|
|
246
|
+
}
|
|
247
|
+
get lastTurnStatus() {
|
|
248
|
+
return this.turnStatus;
|
|
249
|
+
}
|
|
250
|
+
get lastTurnError() {
|
|
251
|
+
return this.turnError;
|
|
252
|
+
}
|
|
253
|
+
async connectAndInitialize() {
|
|
254
|
+
if (!this.connectPromise) {
|
|
255
|
+
this.connectPromise = this.connect();
|
|
256
|
+
}
|
|
257
|
+
await this.connectPromise;
|
|
258
|
+
await this.request('initialize', {
|
|
259
|
+
clientInfo: {
|
|
260
|
+
name: 'dev-sessions',
|
|
261
|
+
title: 'dev-sessions',
|
|
262
|
+
version: '0.1.0'
|
|
263
|
+
},
|
|
264
|
+
capabilities: {
|
|
265
|
+
experimentalApi: true
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
this.notify('initialized', {});
|
|
269
|
+
}
|
|
270
|
+
async request(method, params) {
|
|
271
|
+
if (this.closed) {
|
|
272
|
+
throw new Error(this.turnError ?? 'Codex app-server connection is closed');
|
|
273
|
+
}
|
|
274
|
+
const ws = this.ws;
|
|
275
|
+
if (!ws || ws.readyState !== ws_1.default.OPEN) {
|
|
276
|
+
throw new Error('Codex app-server connection is not open');
|
|
277
|
+
}
|
|
278
|
+
const id = this.nextRequestId;
|
|
279
|
+
this.nextRequestId += 1;
|
|
280
|
+
return new Promise((resolve, reject) => {
|
|
281
|
+
const timeoutHandle = setTimeout(() => {
|
|
282
|
+
const pending = this.pendingRequests.get(id);
|
|
283
|
+
if (!pending) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.pendingRequests.delete(id);
|
|
287
|
+
pending.reject(new Error(`${method} timed out after ${REQUEST_TIMEOUT_MS}ms`));
|
|
288
|
+
}, REQUEST_TIMEOUT_MS);
|
|
289
|
+
this.pendingRequests.set(id, {
|
|
290
|
+
method,
|
|
291
|
+
resolve,
|
|
292
|
+
reject,
|
|
293
|
+
timeoutHandle
|
|
294
|
+
});
|
|
295
|
+
try {
|
|
296
|
+
ws.send(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
this.pendingRequests.delete(id);
|
|
300
|
+
clearTimeout(timeoutHandle);
|
|
301
|
+
reject(error);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async waitForTurnCompletion(timeoutMs) {
|
|
306
|
+
if (this.turnStatus) {
|
|
307
|
+
return {
|
|
308
|
+
completed: true,
|
|
309
|
+
timedOut: false,
|
|
310
|
+
elapsedMs: 0,
|
|
311
|
+
status: this.turnStatus,
|
|
312
|
+
errorMessage: this.turnError
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const safeTimeout = Math.max(1, timeoutMs);
|
|
316
|
+
return new Promise((resolve) => {
|
|
317
|
+
const startTime = Date.now();
|
|
318
|
+
const timeoutHandle = setTimeout(() => {
|
|
319
|
+
this.waiters = this.waiters.filter((waiter) => waiter.timeoutHandle !== timeoutHandle);
|
|
320
|
+
this.turnStatus = 'interrupted';
|
|
321
|
+
this.turnError = 'Timed out waiting for Codex turn completion';
|
|
322
|
+
resolve({
|
|
323
|
+
completed: false,
|
|
324
|
+
timedOut: true,
|
|
325
|
+
elapsedMs: Date.now() - startTime,
|
|
326
|
+
status: 'interrupted',
|
|
327
|
+
errorMessage: this.turnError
|
|
328
|
+
});
|
|
329
|
+
}, safeTimeout);
|
|
330
|
+
this.waiters.push({
|
|
331
|
+
startTime,
|
|
332
|
+
timeoutHandle,
|
|
333
|
+
resolve
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
async close() {
|
|
338
|
+
const ws = this.ws;
|
|
339
|
+
if (!ws || this.closed) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.closing = true;
|
|
343
|
+
await new Promise((resolve) => {
|
|
344
|
+
const finish = () => {
|
|
345
|
+
resolve();
|
|
346
|
+
};
|
|
347
|
+
const timeoutHandle = setTimeout(() => {
|
|
348
|
+
try {
|
|
349
|
+
ws.terminate();
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// no-op
|
|
353
|
+
}
|
|
354
|
+
finish();
|
|
355
|
+
}, CLOSE_TIMEOUT_MS);
|
|
356
|
+
ws.once('close', () => {
|
|
357
|
+
clearTimeout(timeoutHandle);
|
|
358
|
+
finish();
|
|
359
|
+
});
|
|
360
|
+
try {
|
|
361
|
+
if (ws.readyState === ws_1.default.CONNECTING) {
|
|
362
|
+
ws.terminate();
|
|
363
|
+
clearTimeout(timeoutHandle);
|
|
364
|
+
finish();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
ws.close();
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
clearTimeout(timeoutHandle);
|
|
371
|
+
finish();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
async connect() {
|
|
376
|
+
await new Promise((resolve, reject) => {
|
|
377
|
+
const ws = new ws_1.default(this.url, {
|
|
378
|
+
perMessageDeflate: false,
|
|
379
|
+
handshakeTimeout: REQUEST_TIMEOUT_MS
|
|
380
|
+
});
|
|
381
|
+
this.ws = ws;
|
|
382
|
+
const onOpen = () => {
|
|
383
|
+
cleanup();
|
|
384
|
+
this.attachSocketHandlers(ws);
|
|
385
|
+
resolve();
|
|
386
|
+
};
|
|
387
|
+
const onError = (error) => {
|
|
388
|
+
cleanup();
|
|
389
|
+
reject(error);
|
|
390
|
+
};
|
|
391
|
+
const onClose = (code, reason) => {
|
|
392
|
+
cleanup();
|
|
393
|
+
const details = reason.toString().trim();
|
|
394
|
+
const suffix = details.length > 0 ? `: ${details}` : '';
|
|
395
|
+
reject(new Error(`Codex app-server websocket closed during connect (${code})${suffix}`));
|
|
396
|
+
};
|
|
397
|
+
const cleanup = () => {
|
|
398
|
+
ws.off('open', onOpen);
|
|
399
|
+
ws.off('error', onError);
|
|
400
|
+
ws.off('close', onClose);
|
|
401
|
+
};
|
|
402
|
+
ws.once('open', onOpen);
|
|
403
|
+
ws.once('error', onError);
|
|
404
|
+
ws.once('close', onClose);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
attachSocketHandlers(ws) {
|
|
408
|
+
ws.on('message', (data) => {
|
|
409
|
+
const text = typeof data === 'string' ? data : data.toString();
|
|
410
|
+
this.handleMessageFrame(text);
|
|
411
|
+
});
|
|
412
|
+
ws.on('error', (error) => {
|
|
413
|
+
this.failConnection(`Codex app-server websocket error: ${error.message}`);
|
|
414
|
+
});
|
|
415
|
+
ws.on('close', (code, reason) => {
|
|
416
|
+
if (this.closing) {
|
|
417
|
+
this.closed = true;
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const details = reason.toString().trim();
|
|
421
|
+
const suffix = details.length > 0 ? `: ${details}` : '';
|
|
422
|
+
this.failConnection(`Codex app-server websocket closed (${code})${suffix}`);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
handleMessageFrame(frame) {
|
|
426
|
+
if (!frame.includes('\n') && this.stdoutBuffer.length === 0) {
|
|
427
|
+
this.handleMaybeJsonLine(frame);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
this.stdoutBuffer += frame;
|
|
431
|
+
const lines = this.stdoutBuffer.split('\n');
|
|
432
|
+
this.stdoutBuffer = lines.pop() ?? '';
|
|
433
|
+
for (const line of lines) {
|
|
434
|
+
this.handleMaybeJsonLine(line);
|
|
435
|
+
}
|
|
436
|
+
if (this.stdoutBuffer.trim().length > 0) {
|
|
437
|
+
const pendingBuffer = this.stdoutBuffer;
|
|
438
|
+
this.stdoutBuffer = '';
|
|
439
|
+
this.handleMaybeJsonLine(pendingBuffer);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
handleMaybeJsonLine(rawLine) {
|
|
443
|
+
const trimmed = rawLine.trim();
|
|
444
|
+
if (trimmed.length === 0) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
let payload;
|
|
448
|
+
try {
|
|
449
|
+
payload = JSON.parse(trimmed);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// If parsing fails, keep buffering behavior for newline-delimited mode.
|
|
453
|
+
if (!rawLine.includes('\n')) {
|
|
454
|
+
this.stdoutBuffer = rawLine;
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this.handleRpcMessage(payload);
|
|
459
|
+
}
|
|
460
|
+
handleRpcMessage(payload) {
|
|
461
|
+
if (!payload || typeof payload !== 'object') {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if ('id' in payload && typeof payload.id === 'number') {
|
|
465
|
+
this.handleRpcResponse(payload);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if ('method' in payload && typeof payload.method === 'string') {
|
|
469
|
+
this.handleNotification(payload);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
handleRpcResponse(response) {
|
|
473
|
+
const pending = this.pendingRequests.get(response.id);
|
|
474
|
+
if (!pending) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
this.pendingRequests.delete(response.id);
|
|
478
|
+
clearTimeout(pending.timeoutHandle);
|
|
479
|
+
if (response.error) {
|
|
480
|
+
const message = response.error.message ?? 'Unknown JSON-RPC error';
|
|
481
|
+
pending.reject(new Error(`${pending.method} failed: ${message}`));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
pending.resolve(response.result);
|
|
485
|
+
}
|
|
486
|
+
handleNotification(notification) {
|
|
487
|
+
if (notification.method === 'item/agentMessage/delta') {
|
|
488
|
+
const deltaText = extractDeltaText(notification.params);
|
|
489
|
+
if (deltaText.length > 0) {
|
|
490
|
+
this.currentText += deltaText;
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (notification.method !== 'turn/completed') {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const turn = notification.params?.turn;
|
|
498
|
+
const status = extractTurnStatus(turn);
|
|
499
|
+
if (!status) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
this.turnStatus = status;
|
|
503
|
+
this.turnError = extractTurnError(turn);
|
|
504
|
+
this.resolveWaiters({
|
|
505
|
+
completed: true,
|
|
506
|
+
timedOut: false,
|
|
507
|
+
elapsedMs: 0,
|
|
508
|
+
status,
|
|
509
|
+
errorMessage: this.turnError
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
resolveWaiters(baseResult) {
|
|
513
|
+
const waiters = this.waiters.splice(0, this.waiters.length);
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
for (const waiter of waiters) {
|
|
516
|
+
clearTimeout(waiter.timeoutHandle);
|
|
517
|
+
waiter.resolve({
|
|
518
|
+
...baseResult,
|
|
519
|
+
elapsedMs: now - waiter.startTime
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
failConnection(message) {
|
|
524
|
+
if (this.closed) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
this.closed = true;
|
|
528
|
+
this.turnStatus = this.turnStatus ?? 'failed';
|
|
529
|
+
this.turnError = this.turnError ?? message;
|
|
530
|
+
const pendingRequests = [...this.pendingRequests.values()];
|
|
531
|
+
this.pendingRequests.clear();
|
|
532
|
+
for (const pending of pendingRequests) {
|
|
533
|
+
clearTimeout(pending.timeoutHandle);
|
|
534
|
+
pending.reject(new Error(message));
|
|
535
|
+
}
|
|
536
|
+
this.resolveWaiters({
|
|
537
|
+
completed: true,
|
|
538
|
+
timedOut: false,
|
|
539
|
+
elapsedMs: 0,
|
|
540
|
+
status: this.turnStatus,
|
|
541
|
+
errorMessage: this.turnError
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
notify(method, params) {
|
|
545
|
+
const ws = this.ws;
|
|
546
|
+
if (!ws || ws.readyState !== ws_1.default.OPEN) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
ws.send(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function extractThreadId(result) {
|
|
553
|
+
const threadId = result?.thread?.id;
|
|
554
|
+
if (typeof threadId !== 'string' || threadId.trim().length === 0) {
|
|
555
|
+
throw new Error('thread/start did not return a thread ID');
|
|
556
|
+
}
|
|
557
|
+
return threadId;
|
|
558
|
+
}
|
|
559
|
+
function extractDeltaText(params) {
|
|
560
|
+
if (!params || typeof params !== 'object') {
|
|
561
|
+
return '';
|
|
562
|
+
}
|
|
563
|
+
const asRecord = params;
|
|
564
|
+
const directText = asRecord.text;
|
|
565
|
+
if (typeof directText === 'string') {
|
|
566
|
+
return directText;
|
|
567
|
+
}
|
|
568
|
+
const delta = asRecord.delta;
|
|
569
|
+
if (typeof delta === 'string') {
|
|
570
|
+
return delta;
|
|
571
|
+
}
|
|
572
|
+
if (delta && typeof delta === 'object' && typeof delta.text === 'string') {
|
|
573
|
+
return delta.text;
|
|
574
|
+
}
|
|
575
|
+
const item = asRecord.item;
|
|
576
|
+
if (item && typeof item === 'object') {
|
|
577
|
+
const nestedDelta = item.delta;
|
|
578
|
+
if (nestedDelta &&
|
|
579
|
+
typeof nestedDelta === 'object' &&
|
|
580
|
+
typeof nestedDelta.text === 'string') {
|
|
581
|
+
return nestedDelta.text;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return '';
|
|
585
|
+
}
|
|
586
|
+
function extractTurnStatus(turn) {
|
|
587
|
+
if (!turn || typeof turn !== 'object') {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
const status = turn.status;
|
|
591
|
+
if (status === 'completed' || status === 'failed' || status === 'interrupted') {
|
|
592
|
+
return status;
|
|
593
|
+
}
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
function extractTurnError(turn) {
|
|
597
|
+
if (!turn || typeof turn !== 'object') {
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
const error = turn.error;
|
|
601
|
+
if (!error || typeof error !== 'object') {
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
const message = error.message;
|
|
605
|
+
if (typeof message === 'string' && message.length > 0) {
|
|
606
|
+
return message;
|
|
607
|
+
}
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
class CodexAppServerBackend {
|
|
611
|
+
sessionState = new Map();
|
|
612
|
+
daemonManager;
|
|
613
|
+
clientFactory;
|
|
614
|
+
constructor(dependencies = {}) {
|
|
615
|
+
this.daemonManager = dependencies.daemonManager ?? new DefaultCodexAppServerDaemonManager();
|
|
616
|
+
this.clientFactory = dependencies.clientFactory ?? ((url) => new CodexWebSocketRpcClient(url));
|
|
617
|
+
}
|
|
618
|
+
async createSession(championId, workspacePath, model = DEFAULT_MODEL) {
|
|
619
|
+
const { server, result } = await this.withConnectedClient(async (client) => {
|
|
620
|
+
const threadResult = await client.request('thread/start', {
|
|
621
|
+
model,
|
|
622
|
+
cwd: workspacePath,
|
|
623
|
+
approvalPolicy: 'never',
|
|
624
|
+
sandbox: 'danger-full-access',
|
|
625
|
+
ephemeral: false,
|
|
626
|
+
persistExtendedHistory: true,
|
|
627
|
+
experimentalRawEvents: false
|
|
628
|
+
});
|
|
629
|
+
return extractThreadId(threadResult);
|
|
630
|
+
});
|
|
631
|
+
this.ensureSessionState(championId);
|
|
632
|
+
return {
|
|
633
|
+
threadId: result,
|
|
634
|
+
model,
|
|
635
|
+
appServerPid: server.pid,
|
|
636
|
+
appServerPort: server.port
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
async sendMessage(championId, threadId, message, options) {
|
|
640
|
+
if (!options || options.workspacePath.trim().length === 0) {
|
|
641
|
+
throw new Error('Codex workspace path is required to send a message');
|
|
642
|
+
}
|
|
643
|
+
const timeoutMs = Math.max(1, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
644
|
+
const model = options.model ?? DEFAULT_MODEL;
|
|
645
|
+
const state = this.ensureSessionState(championId);
|
|
646
|
+
const { server, result } = await this.withConnectedClient(async (client) => {
|
|
647
|
+
let activeThreadId = threadId.trim();
|
|
648
|
+
if (activeThreadId.length > 0) {
|
|
649
|
+
try {
|
|
650
|
+
await client.request('thread/resume', {
|
|
651
|
+
threadId: activeThreadId,
|
|
652
|
+
cwd: options.workspacePath,
|
|
653
|
+
model,
|
|
654
|
+
approvalPolicy: 'never',
|
|
655
|
+
sandbox: 'danger-full-access',
|
|
656
|
+
persistExtendedHistory: true
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (!this.isResumeNotFoundError(error)) {
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
activeThreadId = '';
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (activeThreadId.length === 0) {
|
|
667
|
+
const threadResult = await client.request('thread/start', {
|
|
668
|
+
model,
|
|
669
|
+
cwd: options.workspacePath,
|
|
670
|
+
approvalPolicy: 'never',
|
|
671
|
+
sandbox: 'danger-full-access',
|
|
672
|
+
ephemeral: false,
|
|
673
|
+
persistExtendedHistory: true,
|
|
674
|
+
experimentalRawEvents: false
|
|
675
|
+
});
|
|
676
|
+
activeThreadId = extractThreadId(threadResult);
|
|
677
|
+
}
|
|
678
|
+
await client.request('turn/start', {
|
|
679
|
+
threadId: activeThreadId,
|
|
680
|
+
input: [{ type: 'text', text: message }]
|
|
681
|
+
});
|
|
682
|
+
const waitResult = await client.waitForTurnCompletion(timeoutMs);
|
|
683
|
+
return {
|
|
684
|
+
threadId: activeThreadId,
|
|
685
|
+
waitResult,
|
|
686
|
+
assistantMessage: client.currentTurnText
|
|
687
|
+
};
|
|
688
|
+
});
|
|
689
|
+
state.lastTurnStatus = result.waitResult.status;
|
|
690
|
+
state.lastTurnError = result.waitResult.errorMessage;
|
|
691
|
+
if (result.assistantMessage.length > 0) {
|
|
692
|
+
state.assistantHistory.push(result.assistantMessage);
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
...result.waitResult,
|
|
696
|
+
threadId: result.threadId,
|
|
697
|
+
assistantMessage: result.assistantMessage,
|
|
698
|
+
appServerPid: server.pid,
|
|
699
|
+
appServerPort: server.port
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
async waitForTurn(championId, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
703
|
+
const state = this.sessionState.get(championId);
|
|
704
|
+
if (!state || !state.lastTurnStatus) {
|
|
705
|
+
return {
|
|
706
|
+
completed: true,
|
|
707
|
+
timedOut: false,
|
|
708
|
+
elapsedMs: 0,
|
|
709
|
+
status: 'completed'
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
if (state.lastTurnStatus === 'interrupted') {
|
|
713
|
+
return {
|
|
714
|
+
completed: false,
|
|
715
|
+
timedOut: true,
|
|
716
|
+
elapsedMs: Math.max(1, timeoutMs),
|
|
717
|
+
status: 'interrupted',
|
|
718
|
+
errorMessage: state.lastTurnError
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
completed: true,
|
|
723
|
+
timedOut: false,
|
|
724
|
+
elapsedMs: 0,
|
|
725
|
+
status: state.lastTurnStatus,
|
|
726
|
+
errorMessage: state.lastTurnError
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
getLastAssistantMessages(championId, count) {
|
|
730
|
+
const state = this.ensureSessionState(championId);
|
|
731
|
+
const safeCount = Math.max(1, count);
|
|
732
|
+
return state.assistantHistory.slice(-safeCount);
|
|
733
|
+
}
|
|
734
|
+
getSessionStatus(championId) {
|
|
735
|
+
const state = this.ensureSessionState(championId);
|
|
736
|
+
if (state.lastTurnStatus === 'failed') {
|
|
737
|
+
const suffix = state.lastTurnError ? `: ${state.lastTurnError}` : '';
|
|
738
|
+
throw new Error(`Codex turn failed${suffix}`);
|
|
739
|
+
}
|
|
740
|
+
if (state.lastTurnStatus === 'interrupted') {
|
|
741
|
+
return 'working';
|
|
742
|
+
}
|
|
743
|
+
return 'idle';
|
|
744
|
+
}
|
|
745
|
+
async killSession(championId, pid, threadId, port) {
|
|
746
|
+
this.sessionState.delete(championId);
|
|
747
|
+
if (!threadId || typeof port !== 'number') {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const activeServer = await this.daemonManager.getServer();
|
|
751
|
+
if (!activeServer) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (typeof pid === 'number' && activeServer.pid !== pid) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (activeServer.port !== port) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
await this.withConnectedClientToServer(activeServer, async (client) => {
|
|
762
|
+
await client.request('thread/archive', {
|
|
763
|
+
threadId
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
if (!(error instanceof Error &&
|
|
769
|
+
/not found|no rollout found|unknown thread|websocket|ECONNREFUSED|socket hang up|connection is not open/i
|
|
770
|
+
.test(error.message))) {
|
|
771
|
+
throw error;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
async stopAppServer() {
|
|
776
|
+
await this.daemonManager.stopServer();
|
|
777
|
+
}
|
|
778
|
+
async sessionExists(_championId, pid, port) {
|
|
779
|
+
return this.daemonManager.isServerRunning(pid, port);
|
|
780
|
+
}
|
|
781
|
+
ensureSessionState(championId) {
|
|
782
|
+
const existing = this.sessionState.get(championId);
|
|
783
|
+
if (existing) {
|
|
784
|
+
return existing;
|
|
785
|
+
}
|
|
786
|
+
const created = {
|
|
787
|
+
assistantHistory: []
|
|
788
|
+
};
|
|
789
|
+
this.sessionState.set(championId, created);
|
|
790
|
+
return created;
|
|
791
|
+
}
|
|
792
|
+
async withConnectedClient(fn) {
|
|
793
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
794
|
+
const server = await this.daemonManager.ensureServer();
|
|
795
|
+
try {
|
|
796
|
+
const result = await this.withConnectedClientToServer(server, fn);
|
|
797
|
+
return {
|
|
798
|
+
server,
|
|
799
|
+
result
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
if (attempt === 0 && this.shouldResetDaemonAfterConnectionFailure(error)) {
|
|
804
|
+
await this.daemonManager.resetServer(server);
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
throw error;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
throw new Error('Unreachable');
|
|
811
|
+
}
|
|
812
|
+
async withConnectedClientToServer(server, fn) {
|
|
813
|
+
const client = this.clientFactory(server.url);
|
|
814
|
+
try {
|
|
815
|
+
await client.connectAndInitialize();
|
|
816
|
+
return await fn(client);
|
|
817
|
+
}
|
|
818
|
+
finally {
|
|
819
|
+
await client.close().catch(() => {
|
|
820
|
+
// Ignore close errors; the primary operation result/error is more useful.
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
shouldResetDaemonAfterConnectionFailure(error) {
|
|
825
|
+
if (!(error instanceof Error)) {
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
return /websocket|ECONNREFUSED|EPIPE|socket hang up|closed during connect|connection is not open/i
|
|
829
|
+
.test(error.message);
|
|
830
|
+
}
|
|
831
|
+
isResumeNotFoundError(error) {
|
|
832
|
+
if (!(error instanceof Error)) {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
return RESUME_NOT_FOUND_PATTERN.test(error.message);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
exports.CodexAppServerBackend = CodexAppServerBackend;
|
|
839
|
+
//# sourceMappingURL=codex-appserver.js.map
|