rivetplane 0.3.2 → 0.4.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 +2 -0
- package/dist/claude-code-discovery.js +484 -0
- package/dist/claude-code-discovery.js.map +1 -0
- package/dist/cli.js +24 -4
- package/dist/cli.js.map +1 -1
- package/dist/client.js +34 -8
- package/dist/client.js.map +1 -1
- package/dist/codex-app-server.js +593 -0
- package/dist/codex-app-server.js.map +1 -0
- package/dist/codex-rollout-discovery.js +312 -0
- package/dist/codex-rollout-discovery.js.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/registry.js +7 -1
- package/dist/registry.js.map +1 -1
- package/dist/session-manager.js.map +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createConnection, createServer } from "node:net";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import WebSocket from "ws";
|
|
8
|
+
import { SessionRegistry } from "./registry.js";
|
|
9
|
+
function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined; }
|
|
10
|
+
function string(value) { return typeof value === "string" ? value : undefined; }
|
|
11
|
+
function number(value) { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
|
|
12
|
+
function array(value) { return Array.isArray(value) ? value : []; }
|
|
13
|
+
function isoSeconds(value) { return new Date((number(value) ?? Date.now() / 1_000) * 1_000).toISOString(); }
|
|
14
|
+
function summary(value, limit = 2_000) { const text = typeof value === "string" ? value : JSON.stringify(value) ?? ""; return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`; }
|
|
15
|
+
function requestKey(id) { return `${typeof id}:${String(id)}`; }
|
|
16
|
+
function threadStatus(value) {
|
|
17
|
+
const type = string(object(value)?.type);
|
|
18
|
+
return type === "active" ? "running" : type === "systemError" ? "error" : "waiting_input";
|
|
19
|
+
}
|
|
20
|
+
function questionOptions(value) {
|
|
21
|
+
return array(value).flatMap((entry) => { const option = object(entry); const label = string(option?.label); const description = string(option?.description); return label ? [{ label, ...(description ? { description } : {}) }] : []; });
|
|
22
|
+
}
|
|
23
|
+
async function mapLimit(values, concurrency, operation) {
|
|
24
|
+
let index = 0;
|
|
25
|
+
await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), values.length) }, async () => {
|
|
26
|
+
while (index < values.length) {
|
|
27
|
+
const value = values[index++];
|
|
28
|
+
if (value !== undefined)
|
|
29
|
+
await operation(value);
|
|
30
|
+
}
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
async function availablePort() {
|
|
34
|
+
const server = createServer();
|
|
35
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
|
36
|
+
const address = server.address();
|
|
37
|
+
if (!address || typeof address === "string")
|
|
38
|
+
throw new Error("Could not allocate a Codex app-server port");
|
|
39
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
40
|
+
return address.port;
|
|
41
|
+
}
|
|
42
|
+
async function unixSocketIsActive(path) {
|
|
43
|
+
return await new Promise((resolve) => {
|
|
44
|
+
const socket = createConnection(path);
|
|
45
|
+
const finish = (active) => { clearTimeout(timer); socket.destroy(); resolve(active); };
|
|
46
|
+
const timer = setTimeout(() => finish(false), 250);
|
|
47
|
+
socket.once("connect", () => finish(true));
|
|
48
|
+
socket.once("error", () => finish(false));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
class CodexTarget {
|
|
52
|
+
manager;
|
|
53
|
+
thread_id;
|
|
54
|
+
constructor(manager, thread_id) {
|
|
55
|
+
this.manager = manager;
|
|
56
|
+
this.thread_id = thread_id;
|
|
57
|
+
}
|
|
58
|
+
sendMessage(text) { return this.manager.sendMessage(this.thread_id, text); }
|
|
59
|
+
respondToPending(id, response, scope) { return this.manager.respondToPending(this.thread_id, id, response, scope); }
|
|
60
|
+
interrupt() { return this.manager.interrupt(this.thread_id); }
|
|
61
|
+
}
|
|
62
|
+
export class CodexAppServerManager {
|
|
63
|
+
machine_id;
|
|
64
|
+
registry;
|
|
65
|
+
options;
|
|
66
|
+
directory;
|
|
67
|
+
#endpoint;
|
|
68
|
+
#token;
|
|
69
|
+
#token_path;
|
|
70
|
+
#child;
|
|
71
|
+
#socket;
|
|
72
|
+
#timer;
|
|
73
|
+
#polling = false;
|
|
74
|
+
#request_id = 1;
|
|
75
|
+
#requests = new Map();
|
|
76
|
+
#pending = new Map();
|
|
77
|
+
#threads = new Map();
|
|
78
|
+
#unknown = new Set();
|
|
79
|
+
#streamed_items = new Set();
|
|
80
|
+
#delta_counts = new Map();
|
|
81
|
+
#online = false;
|
|
82
|
+
#version = "unknown";
|
|
83
|
+
#transport = "none";
|
|
84
|
+
#operations = { persisted_discovery: true, live_attachment: false, messaging: false, interrupt: false, question_response: false, approval_response: false };
|
|
85
|
+
#models = [];
|
|
86
|
+
#default_model;
|
|
87
|
+
#pollFailures = 0;
|
|
88
|
+
#nextPollAt = 0;
|
|
89
|
+
#stopped = false;
|
|
90
|
+
constructor(machine_id, registry, options = {}) {
|
|
91
|
+
this.machine_id = machine_id;
|
|
92
|
+
this.registry = registry;
|
|
93
|
+
this.options = options;
|
|
94
|
+
this.directory = options.directory ?? process.cwd();
|
|
95
|
+
this.#endpoint = options.endpoint;
|
|
96
|
+
this.#token = options.token;
|
|
97
|
+
}
|
|
98
|
+
async start() { this.#stopped = false; if (this.options.managed)
|
|
99
|
+
await this.#launch(); await this.poll(); this.#timer = setInterval(() => void this.poll(), this.options.interval_ms ?? 2_000); this.#timer.unref(); }
|
|
100
|
+
async stop() {
|
|
101
|
+
this.#stopped = true;
|
|
102
|
+
if (this.#timer)
|
|
103
|
+
clearInterval(this.#timer);
|
|
104
|
+
this.#timer = undefined;
|
|
105
|
+
this.#close(new Error("Codex app-server stopped"));
|
|
106
|
+
if (this.#child) {
|
|
107
|
+
const child = this.#child;
|
|
108
|
+
this.#child = undefined;
|
|
109
|
+
child.kill("SIGTERM");
|
|
110
|
+
}
|
|
111
|
+
if (this.options.managed && this.#transport === "unix" && this.#endpoint)
|
|
112
|
+
await unlink(this.#endpoint.replace(/^unix:\/\//, "")).catch(() => undefined);
|
|
113
|
+
if (this.options.managed && this.#token_path)
|
|
114
|
+
await unlink(this.#token_path).catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
target(id) { return this.#online && this.#threads.has(id) ? new CodexTarget(this, id) : undefined; }
|
|
117
|
+
harnesses() { return this.#endpoint ? [{ harness_type: "codex", discovered_sessions: this.#threads.size, attached_sessions: this.#online ? this.#threads.size : 0, capabilities: this.health() }] : []; }
|
|
118
|
+
health() {
|
|
119
|
+
const support = (value, reason) => value ? { supported: true, mode: "read_write" } : { supported: false, mode: "unsupported", reason };
|
|
120
|
+
const offline = "The Codex app-server transport is not connected.";
|
|
121
|
+
return { persisted_discovery: support(this.#online, offline), discovery: support(this.#online, offline), transcript: support(this.#online, offline), live_attachment: support(this.#operations.live_attachment, offline), messaging: support(this.#operations.messaging, "Codex turn/start is not available."), interrupt: support(this.#operations.interrupt, "Codex turn/interrupt is not available."), question_response: support(this.#operations.question_response, "Codex user-input responses are not available."), approval_response: support(this.#operations.approval_response, "Codex approval responses are not available."), transport: this.#transport, managed: Boolean(this.options.managed), endpoint: this.#endpoint ? this.#endpoint.replace(/token=[^&]+/g, "token=<redacted>") : null };
|
|
122
|
+
}
|
|
123
|
+
capabilities() {
|
|
124
|
+
if (!this.#endpoint)
|
|
125
|
+
return undefined;
|
|
126
|
+
return { machine_id: this.machine_id, harness_type: "codex", can_create_session: this.#online, directories: [this.directory], models: this.#models, ...(this.#default_model ? { default_model: { provider_id: "openai", model_id: this.#default_model } } : {}), reported_at: new Date().toISOString(), session_capabilities: this.health(), transport: this.#transport, harness_version: this.#version,
|
|
127
|
+
limitations: ["Persisted rollout discovery is read-only.", "Rivetplane does not attach to independently launched stdio app-server processes.", "Exact responses cover command and file approvals plus item/tool/requestUserInput. Permissions-profile approvals and MCP elicitation fail closed."] };
|
|
128
|
+
}
|
|
129
|
+
async createSession(command) {
|
|
130
|
+
if (!this.#online)
|
|
131
|
+
throw new Error("Codex app-server is not connected");
|
|
132
|
+
const result = object(await this.#request("thread/start", { cwd: command.cwd, model: command.model.model_id, approvalPolicy: "untrusted", sandbox: "workspace-write", serviceName: "rivetplane" }));
|
|
133
|
+
const thread = object(result?.thread);
|
|
134
|
+
const id = string(thread?.id);
|
|
135
|
+
if (!thread || !id)
|
|
136
|
+
throw new Error("Codex thread/start returned no thread ID");
|
|
137
|
+
this.#syncThread(thread);
|
|
138
|
+
const state = this.#threads.get(id);
|
|
139
|
+
state.loaded = true;
|
|
140
|
+
state.retain_until = (this.options.now?.() ?? Date.now()) + (this.options.new_thread_grace_ms ?? 60_000);
|
|
141
|
+
return id;
|
|
142
|
+
}
|
|
143
|
+
async poll() {
|
|
144
|
+
const now = this.options.now?.() ?? Date.now();
|
|
145
|
+
if (this.#polling || now < this.#nextPollAt)
|
|
146
|
+
return;
|
|
147
|
+
this.#polling = true;
|
|
148
|
+
try {
|
|
149
|
+
if (this.options.managed && (!this.#child || this.#child.exitCode !== null))
|
|
150
|
+
await this.#launch();
|
|
151
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN)
|
|
152
|
+
await this.#connect();
|
|
153
|
+
await this.#listThreads();
|
|
154
|
+
this.#pollFailures = 0;
|
|
155
|
+
this.#nextPollAt = 0;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
this.#online = false;
|
|
159
|
+
this.#operations.live_attachment = false;
|
|
160
|
+
if (!this.#stopped) {
|
|
161
|
+
const delay = this.#retryDelay(++this.#pollFailures);
|
|
162
|
+
this.#nextPollAt = now + delay;
|
|
163
|
+
this.registry.emit("warning", new Error(`Codex app-server failed; retrying in ${Math.ceil(delay / 1_000)}s: ${error instanceof Error ? error.message : String(error)}`));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
this.#polling = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async sendMessage(threadId, text) {
|
|
171
|
+
const state = this.#requireThread(threadId);
|
|
172
|
+
if (!state.loaded) {
|
|
173
|
+
await this.#request("thread/resume", { threadId });
|
|
174
|
+
state.loaded = true;
|
|
175
|
+
}
|
|
176
|
+
const result = object(await this.#request("turn/start", { threadId, clientUserMessageId: randomUUID(), input: [{ type: "text", text, text_elements: [] }] }));
|
|
177
|
+
const turn = object(result?.turn);
|
|
178
|
+
state.turn_id = string(turn?.id);
|
|
179
|
+
this.#operations.messaging = true;
|
|
180
|
+
this.registry.setStatus(threadId, "running");
|
|
181
|
+
}
|
|
182
|
+
async interrupt(threadId) {
|
|
183
|
+
const state = this.#requireThread(threadId);
|
|
184
|
+
if (!state.turn_id)
|
|
185
|
+
throw new Error("Codex thread has no active turn to interrupt");
|
|
186
|
+
await this.#request("turn/interrupt", { threadId, turnId: state.turn_id });
|
|
187
|
+
this.#operations.interrupt = true;
|
|
188
|
+
}
|
|
189
|
+
async setCollaborationMode(threadId, mode) {
|
|
190
|
+
this.#requireThread(threadId);
|
|
191
|
+
const model = this.#default_model ?? this.#models[0]?.model_id;
|
|
192
|
+
if (!model)
|
|
193
|
+
throw new Error("Codex model roster is not available");
|
|
194
|
+
await this.#request("thread/settings/update", { threadId, collaborationMode: { mode, settings: { model, reasoning_effort: mode === "plan" ? "medium" : null, developer_instructions: null } } });
|
|
195
|
+
}
|
|
196
|
+
async respondToPending(threadId, pendingId, response, scope) {
|
|
197
|
+
const pending = this.#pending.get(pendingId);
|
|
198
|
+
const current = this.registry.get(threadId)?.pending;
|
|
199
|
+
if (!pending || !current || current.id !== pendingId || string(pending.params.threadId) !== threadId)
|
|
200
|
+
throw new Error(`Codex request ${pendingId} is no longer pending`);
|
|
201
|
+
let result;
|
|
202
|
+
if (pending.method === "item/tool/requestUserInput") {
|
|
203
|
+
const questions = array(pending.params.questions).map(object).filter((item) => Boolean(item));
|
|
204
|
+
let supplied;
|
|
205
|
+
try {
|
|
206
|
+
const parsed = object(JSON.parse(response));
|
|
207
|
+
if (parsed)
|
|
208
|
+
supplied = Object.fromEntries(Object.entries(parsed).map(([key, value]) => [key, Array.isArray(value) ? value.map(String) : [String(value)]]));
|
|
209
|
+
}
|
|
210
|
+
catch { /* one answer */ }
|
|
211
|
+
if (questions.length > 1 && !supplied)
|
|
212
|
+
throw new Error("A multi-question Codex request needs a JSON object keyed by question ID");
|
|
213
|
+
const answers = Object.fromEntries(questions.map((question, index) => { const id = string(question.id) ?? String(index); return [id, { answers: supplied?.[id] ?? (index === 0 ? [response] : []) }]; }));
|
|
214
|
+
result = { answers };
|
|
215
|
+
this.#operations.question_response = true;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
if (response !== "approve" && response !== "deny")
|
|
219
|
+
throw new Error("Codex approval response must be approve or deny");
|
|
220
|
+
if (scope === "always_this_tool")
|
|
221
|
+
throw new Error("Codex app-server does not expose an exact always-this-tool decision for this request");
|
|
222
|
+
const decision = response === "deny" ? "decline" : scope === "always_session" ? "acceptForSession" : "accept";
|
|
223
|
+
result = { decision };
|
|
224
|
+
this.#operations.approval_response = true;
|
|
225
|
+
}
|
|
226
|
+
this.#send({ id: pending.rpc_id, result });
|
|
227
|
+
this.#pending.delete(pendingId);
|
|
228
|
+
if (current.type === "approval")
|
|
229
|
+
this.registry.append(threadId, "permission_response", { approval_id: pendingId, resolution: response === "deny" ? "deny" : "approve", ...(scope ? { scope } : {}) });
|
|
230
|
+
this.registry.setPending(threadId, null);
|
|
231
|
+
this.registry.setStatus(threadId, "running");
|
|
232
|
+
}
|
|
233
|
+
async #launch() {
|
|
234
|
+
if (this.#child && this.#child.exitCode === null)
|
|
235
|
+
return;
|
|
236
|
+
const platform = this.options.platform ?? process.platform;
|
|
237
|
+
const spawnProcess = this.options.spawn_process ?? spawn;
|
|
238
|
+
const executable = this.options.executable ?? "codex";
|
|
239
|
+
let args;
|
|
240
|
+
if (platform === "win32") {
|
|
241
|
+
const port = await availablePort();
|
|
242
|
+
const secretDirectory = join(homedir(), ".config", "harness-cp", "codex");
|
|
243
|
+
await mkdir(secretDirectory, { recursive: true, mode: 0o700 });
|
|
244
|
+
const tokenPath = join(secretDirectory, "app-server-token");
|
|
245
|
+
this.#token_path = tokenPath;
|
|
246
|
+
this.#token = randomBytes(32).toString("base64url");
|
|
247
|
+
await writeFile(tokenPath, this.#token, { mode: 0o600 });
|
|
248
|
+
this.#endpoint = `ws://127.0.0.1:${port}`;
|
|
249
|
+
args = ["app-server", "--listen", this.#endpoint, "--ws-auth", "capability-token", "--ws-token-file", tokenPath];
|
|
250
|
+
this.#transport = "loopback-websocket";
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const socketPath = this.options.socket_path ?? join(homedir(), ".config", "harness-cp", "codex", "app-server.sock");
|
|
254
|
+
await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
255
|
+
await chmod(dirname(socketPath), 0o700);
|
|
256
|
+
if (await unixSocketIsActive(socketPath))
|
|
257
|
+
throw new Error(`Refusing to replace an active Codex socket at ${socketPath}`);
|
|
258
|
+
await unlink(socketPath).catch(() => undefined);
|
|
259
|
+
this.#endpoint = `unix://${socketPath}`;
|
|
260
|
+
args = ["app-server", "--listen", this.#endpoint];
|
|
261
|
+
this.#transport = "unix";
|
|
262
|
+
}
|
|
263
|
+
const child = spawnProcess(executable, args, { cwd: this.directory, stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
|
|
264
|
+
this.#child = child;
|
|
265
|
+
child.stderr?.on("data", (chunk) => { const value = chunk.toString("utf8").trim(); if (value)
|
|
266
|
+
this.registry.emit("log", `Codex app-server: ${summary(value, 500)}`); });
|
|
267
|
+
child.once("exit", () => { if (this.#child === child) {
|
|
268
|
+
this.#child = undefined;
|
|
269
|
+
this.#close(new Error("Managed Codex app-server exited"));
|
|
270
|
+
} });
|
|
271
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
272
|
+
try {
|
|
273
|
+
await this.#connect();
|
|
274
|
+
if (platform !== "win32" && this.#endpoint)
|
|
275
|
+
await chmod(this.#endpoint.replace(/^unix:\/\//, ""), 0o600);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
throw new Error("Managed Codex app-server did not become ready");
|
|
283
|
+
}
|
|
284
|
+
async #connect() {
|
|
285
|
+
if (!this.#endpoint)
|
|
286
|
+
return;
|
|
287
|
+
if (this.#socket?.readyState === WebSocket.OPEN)
|
|
288
|
+
return;
|
|
289
|
+
const endpoint = this.#endpoint;
|
|
290
|
+
const isUnix = endpoint.startsWith("unix://");
|
|
291
|
+
const socketPath = isUnix ? endpoint.slice("unix://".length) : undefined;
|
|
292
|
+
if (isUnix && socketPath) {
|
|
293
|
+
const info = await stat(socketPath);
|
|
294
|
+
if (this.options.managed)
|
|
295
|
+
await chmod(socketPath, 0o600);
|
|
296
|
+
else if ((typeof process.getuid === "function" && Number(info.uid) !== process.getuid()) || (info.mode & 0o077) !== 0)
|
|
297
|
+
throw new Error("Configured Codex Unix socket must be owned by this user and have mode 0600");
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
const url = new URL(endpoint);
|
|
301
|
+
const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
302
|
+
if (!loopback && (url.protocol !== "wss:" || !this.#token))
|
|
303
|
+
throw new Error("A non-loopback Codex endpoint needs WSS and a bearer token");
|
|
304
|
+
}
|
|
305
|
+
const socket = isUnix
|
|
306
|
+
? new WebSocket("ws://localhost/rpc", { perMessageDeflate: false, createConnection: () => createConnection(socketPath) })
|
|
307
|
+
: new WebSocket(endpoint, { ...(this.#token ? { headers: { authorization: `Bearer ${this.#token}` } } : {}) });
|
|
308
|
+
await new Promise((resolve, reject) => { const timer = setTimeout(() => { socket.terminate(); reject(new Error("connection timed out")); }, 3_000); socket.once("open", () => { clearTimeout(timer); resolve(); }); socket.once("error", (error) => { clearTimeout(timer); reject(error); }); });
|
|
309
|
+
this.#socket = socket;
|
|
310
|
+
socket.on("message", (data) => this.#receive(data.toString()));
|
|
311
|
+
socket.on("close", () => this.#close(new Error("Codex app-server connection closed")));
|
|
312
|
+
socket.on("error", () => undefined);
|
|
313
|
+
const initialized = object(await this.#request("initialize", { clientInfo: { name: "rivetplane", title: "Rivetplane", version: "0.3.0" }, capabilities: { experimentalApi: true } }));
|
|
314
|
+
this.#version = string(initialized?.userAgent) ?? this.#version;
|
|
315
|
+
this.#send({ method: "initialized", params: {} });
|
|
316
|
+
this.#online = true;
|
|
317
|
+
this.#operations = { ...this.#operations, live_attachment: true, messaging: true, interrupt: true, question_response: true, approval_response: true };
|
|
318
|
+
await this.#modelsList();
|
|
319
|
+
}
|
|
320
|
+
async #listThreads() {
|
|
321
|
+
if (!this.#endpoint)
|
|
322
|
+
return;
|
|
323
|
+
const maxThreads = Math.max(1, this.options.max_threads ?? 48);
|
|
324
|
+
let cursor = null;
|
|
325
|
+
let count = 0;
|
|
326
|
+
const found = new Set();
|
|
327
|
+
do {
|
|
328
|
+
const response = object(await this.#request("thread/list", { limit: Math.min(100, maxThreads - count), cursor, sortKey: "updated_at", sortDirection: "desc" }));
|
|
329
|
+
const threads = array(response?.data).map(object).filter((item) => Boolean(item));
|
|
330
|
+
for (const thread of threads) {
|
|
331
|
+
const id = string(thread.id);
|
|
332
|
+
if (!id || thread.parentThreadId)
|
|
333
|
+
continue;
|
|
334
|
+
if (count++ >= maxThreads)
|
|
335
|
+
break;
|
|
336
|
+
found.add(id);
|
|
337
|
+
this.#syncThread(thread);
|
|
338
|
+
}
|
|
339
|
+
cursor = string(response?.nextCursor) ?? null;
|
|
340
|
+
} while (cursor && count < maxThreads);
|
|
341
|
+
for (const [id, state] of this.#threads)
|
|
342
|
+
if (!found.has(id)) {
|
|
343
|
+
const current = this.registry.get(id);
|
|
344
|
+
const now = this.options.now?.() ?? Date.now();
|
|
345
|
+
if (now < (state.retain_until ?? 0) || current?.status === "running" || current?.pending)
|
|
346
|
+
continue;
|
|
347
|
+
state.missing_polls = (state.missing_polls ?? 0) + 1;
|
|
348
|
+
if (state.missing_polls < (this.options.missing_poll_limit ?? 3))
|
|
349
|
+
continue;
|
|
350
|
+
this.#threads.delete(id);
|
|
351
|
+
if (object(current?.metadata)?.codex_control === "app-server")
|
|
352
|
+
this.registry.remove(id);
|
|
353
|
+
}
|
|
354
|
+
const now = this.options.now?.() ?? Date.now();
|
|
355
|
+
const history = [...found].slice(0, Math.max(0, this.options.history_threads ?? 12)).filter((id) => {
|
|
356
|
+
const state = this.#threads.get(id);
|
|
357
|
+
return state && !state.history_loaded && now >= (state.history_retry_at ?? 0);
|
|
358
|
+
});
|
|
359
|
+
await mapLimit(history, this.options.concurrency ?? 2, async (id) => {
|
|
360
|
+
const state = this.#threads.get(id);
|
|
361
|
+
if (!state)
|
|
362
|
+
return;
|
|
363
|
+
try {
|
|
364
|
+
await this.#readThread(id);
|
|
365
|
+
state.history_loaded = true;
|
|
366
|
+
state.history_attempts = 0;
|
|
367
|
+
state.history_retry_at = 0;
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
state.history_attempts = (state.history_attempts ?? 0) + 1;
|
|
371
|
+
const delay = this.#retryDelay(state.history_attempts);
|
|
372
|
+
state.history_retry_at = now + delay;
|
|
373
|
+
this.registry.emit("warning", new Error(`Codex thread ${id} history failed; retrying in ${Math.ceil(delay / 1_000)}s: ${error instanceof Error ? error.message : String(error)}`));
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
#syncThread(thread) {
|
|
378
|
+
const id = string(thread.id);
|
|
379
|
+
const current = this.registry.get(id);
|
|
380
|
+
const state = this.#threads.get(id) ?? { loaded: false };
|
|
381
|
+
state.missing_polls = 0;
|
|
382
|
+
this.#threads.set(id, state);
|
|
383
|
+
const status = current?.pending?.type === "approval" ? "waiting_approval" : current?.pending?.type === "question" ? "waiting_input" : threadStatus(thread.status);
|
|
384
|
+
this.registry.upsert({ id, machine_id: this.machine_id, harness_type: "codex", cwd: string(thread.cwd) ?? this.directory, status, created_at: isoSeconds(thread.createdAt), last_activity_at: isoSeconds(thread.updatedAt), pending: current?.pending ?? null,
|
|
385
|
+
title: string(thread.name) ?? string(thread.preview), read_only: false, ...(string(thread.modelProvider) ? { model: { provider_id: string(thread.modelProvider), model_id: "unknown" } } : {}),
|
|
386
|
+
metadata: { codex_control: "app-server", live_process_attached: true, transport: this.#transport, cli_version: string(thread.cliVersion) ?? this.#version, source: thread.source } });
|
|
387
|
+
}
|
|
388
|
+
async #readThread(threadId) {
|
|
389
|
+
const response = object(await this.#request("thread/read", { threadId, includeTurns: true }));
|
|
390
|
+
const thread = object(response?.thread);
|
|
391
|
+
if (!thread)
|
|
392
|
+
return;
|
|
393
|
+
for (const turn of array(thread.turns).map(object).filter((item) => Boolean(item))) {
|
|
394
|
+
const time = number(turn.completedAt ?? turn.startedAt);
|
|
395
|
+
for (const item of array(turn.items).map(object).filter((entry) => Boolean(entry)))
|
|
396
|
+
this.#syncItem(threadId, item, time ? time * 1_000 : undefined);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async #modelsList() {
|
|
400
|
+
try {
|
|
401
|
+
const response = object(await this.#request("model/list", { limit: 100 }));
|
|
402
|
+
const data = array(response?.data).map(object).filter((item) => Boolean(item));
|
|
403
|
+
this.#models = data.flatMap((item) => { const id = string(item.model) ?? string(item.id); return id ? [{ provider_id: "openai", model_id: id, name: string(item.displayName) ?? id }] : []; });
|
|
404
|
+
this.#default_model = data.find((item) => item.isDefault === true) && (string(data.find((item) => item.isDefault === true)?.model) ?? string(data.find((item) => item.isDefault === true)?.id));
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
this.#models = [];
|
|
408
|
+
this.#default_model = undefined;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
#receive(raw) {
|
|
412
|
+
let message;
|
|
413
|
+
try {
|
|
414
|
+
const parsed = object(JSON.parse(raw));
|
|
415
|
+
if (!parsed)
|
|
416
|
+
return;
|
|
417
|
+
message = parsed;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
this.registry.emit("warning", new Error("Codex app-server sent invalid JSON"));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if ((typeof message.id === "number" || typeof message.id === "string") && ("result" in message || "error" in message) && !message.method) {
|
|
424
|
+
const pending = this.#requests.get(requestKey(message.id));
|
|
425
|
+
if (!pending)
|
|
426
|
+
return;
|
|
427
|
+
this.#requests.delete(requestKey(message.id));
|
|
428
|
+
clearTimeout(pending.timer);
|
|
429
|
+
const error = object(message.error);
|
|
430
|
+
if (error) {
|
|
431
|
+
if (number(error.code) === -32601)
|
|
432
|
+
this.#disableMethod(pending.method);
|
|
433
|
+
pending.reject(new Error(`${pending.method}: ${string(error.message) ?? "request failed"}`));
|
|
434
|
+
}
|
|
435
|
+
else
|
|
436
|
+
pending.resolve(message.result);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const method = string(message.method);
|
|
440
|
+
const params = object(message.params) ?? {};
|
|
441
|
+
if (!method)
|
|
442
|
+
return;
|
|
443
|
+
if (typeof message.id === "number" || typeof message.id === "string") {
|
|
444
|
+
this.#serverRequest(method, message.id, params);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
this.#notification(method, params);
|
|
448
|
+
}
|
|
449
|
+
#serverRequest(method, rpcId, params) {
|
|
450
|
+
if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") {
|
|
451
|
+
const threadId = string(params.threadId);
|
|
452
|
+
if (!threadId || !this.registry.get(threadId)) {
|
|
453
|
+
this.#send({ id: rpcId, error: { code: -32602, message: "Unknown thread" } });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const id = String(rpcId);
|
|
457
|
+
const pending = { type: "approval", id, session_id: threadId, tool_name: method.includes("commandExecution") ? "commandExecution" : "fileChange", tool_input_summary: summary(params.command ?? params.reason ?? params), requested_at: new Date(number(params.startedAtMs) ?? Date.now()).toISOString() };
|
|
458
|
+
this.#pending.set(id, { rpc_id: rpcId, method, params });
|
|
459
|
+
this.registry.setPending(threadId, pending);
|
|
460
|
+
this.registry.append(threadId, "permission_request", { approval_id: id, tool_name: pending.tool_name, tool_input_summary: pending.tool_input_summary }, { id: `codex-request-${requestKey(rpcId)}` });
|
|
461
|
+
this.registry.setStatus(threadId, "waiting_approval");
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (method === "item/tool/requestUserInput") {
|
|
465
|
+
const threadId = string(params.threadId);
|
|
466
|
+
if (!threadId || !this.registry.get(threadId)) {
|
|
467
|
+
this.#send({ id: rpcId, error: { code: -32602, message: "Unknown thread" } });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const questions = array(params.questions).map(object).filter((item) => Boolean(item));
|
|
471
|
+
const id = String(rpcId);
|
|
472
|
+
const pending = { type: "question", id, session_id: threadId, prompt: questions.map((item) => string(item.question) ?? "").join("\n"), header: questions.map((item) => string(item.header) ?? "").join(" / "),
|
|
473
|
+
options: questions.flatMap((item) => questionOptions(item.options).map((option) => option.label)), option_details: questions.flatMap((item) => questionOptions(item.options)),
|
|
474
|
+
questions: questions.map((item) => ({ prompt: string(item.question) ?? "", header: string(item.header) ?? "", options: questionOptions(item.options), custom: item.isOther === true })), tool_call_id: string(params.itemId), requested_at: new Date().toISOString() };
|
|
475
|
+
this.#pending.set(id, { rpc_id: rpcId, method, params });
|
|
476
|
+
this.registry.setPending(threadId, pending);
|
|
477
|
+
this.registry.setStatus(threadId, "waiting_input");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
this.#send({ id: rpcId, error: { code: -32601, message: `Unsupported server request: ${method}` } });
|
|
481
|
+
this.#diagnostic(method);
|
|
482
|
+
}
|
|
483
|
+
#notification(method, params) {
|
|
484
|
+
const threadId = string(params.threadId);
|
|
485
|
+
if (method === "turn/started" && threadId) {
|
|
486
|
+
const turn = object(params.turn);
|
|
487
|
+
const state = this.#threads.get(threadId);
|
|
488
|
+
if (state)
|
|
489
|
+
state.turn_id = string(turn?.id);
|
|
490
|
+
if (this.registry.get(threadId))
|
|
491
|
+
this.registry.setStatus(threadId, "running");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (method === "turn/completed" && threadId) {
|
|
495
|
+
const turn = object(params.turn);
|
|
496
|
+
const state = this.#threads.get(threadId);
|
|
497
|
+
if (state)
|
|
498
|
+
state.turn_id = undefined;
|
|
499
|
+
if (this.registry.get(threadId))
|
|
500
|
+
this.registry.setStatus(threadId, string(turn?.status) === "failed" ? "error" : "waiting_input");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if ((method === "item/completed" || method === "item/started") && threadId) {
|
|
504
|
+
const item = object(params.item);
|
|
505
|
+
if (item)
|
|
506
|
+
this.#syncItem(threadId, item, number(params.completedAtMs ?? params.startedAtMs));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (method === "item/agentMessage/delta" && threadId) {
|
|
510
|
+
const delta = string(params.delta);
|
|
511
|
+
const itemId = string(params.itemId);
|
|
512
|
+
if (delta && itemId && this.registry.get(threadId)) {
|
|
513
|
+
const count = (this.#delta_counts.get(itemId) ?? 0) + 1;
|
|
514
|
+
this.#delta_counts.set(itemId, count);
|
|
515
|
+
this.#streamed_items.add(itemId);
|
|
516
|
+
this.registry.append(threadId, "agent_message", { text: delta }, { id: `codex-delta-${itemId}-${count}` });
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (["thread/started", "thread/status/changed", "thread/name/updated"].includes(method)) {
|
|
521
|
+
const thread = object(params.thread);
|
|
522
|
+
if (thread?.id)
|
|
523
|
+
this.#syncThread(thread);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (method === "error" || method === "warning") {
|
|
527
|
+
this.registry.emit("warning", new Error(`Codex ${method}: ${summary(params, 1_000)}`));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (!method.startsWith("account/") && !method.startsWith("serverRequest/"))
|
|
531
|
+
this.#diagnostic(method);
|
|
532
|
+
}
|
|
533
|
+
#syncItem(threadId, item, timeMs) {
|
|
534
|
+
if (!this.registry.get(threadId))
|
|
535
|
+
return;
|
|
536
|
+
const id = string(item.id) ?? randomUUID();
|
|
537
|
+
const ts = new Date(timeMs ?? Date.now()).toISOString();
|
|
538
|
+
const type = string(item.type);
|
|
539
|
+
if (type === "userMessage") {
|
|
540
|
+
const text = array(item.content).map(object).flatMap((part) => string(part?.text) ?? []).join("");
|
|
541
|
+
if (text)
|
|
542
|
+
this.registry.append(threadId, "user_message", { text }, { id: `codex-item-${id}`, ts });
|
|
543
|
+
}
|
|
544
|
+
else if (type === "agentMessage") {
|
|
545
|
+
const text = string(item.text);
|
|
546
|
+
if (text && !this.#streamed_items.has(id))
|
|
547
|
+
this.registry.append(threadId, "agent_message", { text }, { id: `codex-item-${id}`, ts });
|
|
548
|
+
}
|
|
549
|
+
else if (["commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall"].includes(type ?? "")) {
|
|
550
|
+
const tool = type ?? "tool";
|
|
551
|
+
this.registry.append(threadId, "tool_call", { tool_call_id: id, tool_name: tool, input_summary: summary(item.command ?? item.arguments ?? item.changes) }, { id: `codex-item-${id}-call`, ts });
|
|
552
|
+
if (["completed", "failed", "declined"].includes(string(item.status) ?? ""))
|
|
553
|
+
this.registry.append(threadId, "tool_result", { tool_call_id: id, output_summary: summary(item.aggregatedOutput ?? item.result ?? item.error), is_error: string(item.status) !== "completed" }, { id: `codex-item-${id}-result`, ts });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
#request(method, params) {
|
|
557
|
+
const id = this.#request_id++;
|
|
558
|
+
return new Promise((resolve, reject) => {
|
|
559
|
+
const timer = setTimeout(() => { this.#requests.delete(requestKey(id)); reject(new Error(`${method} timed out`)); }, this.options.request_timeout_ms ?? 10_000);
|
|
560
|
+
timer.unref();
|
|
561
|
+
this.#requests.set(requestKey(id), { resolve, reject, timer, method });
|
|
562
|
+
try {
|
|
563
|
+
this.#send({ method, id, params });
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
this.#requests.delete(requestKey(id));
|
|
568
|
+
reject(error);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
#send(message) { if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN)
|
|
573
|
+
throw new Error("Codex app-server is not connected"); this.#socket.send(JSON.stringify(message)); }
|
|
574
|
+
#close(error) { const socket = this.#socket; this.#socket = undefined; this.#online = false; this.#operations.live_attachment = false; if (socket && socket.readyState === WebSocket.OPEN)
|
|
575
|
+
socket.close(); for (const request of this.#requests.values()) {
|
|
576
|
+
clearTimeout(request.timer);
|
|
577
|
+
request.reject(error);
|
|
578
|
+
} this.#requests.clear(); this.#pending.clear(); for (const id of this.#threads.keys()) {
|
|
579
|
+
const current = this.registry.get(id);
|
|
580
|
+
if (object(current?.metadata)?.codex_control === "app-server")
|
|
581
|
+
this.registry.remove(id);
|
|
582
|
+
} this.#threads.clear(); }
|
|
583
|
+
#requireThread(id) { const state = this.#threads.get(id); if (!state)
|
|
584
|
+
throw new Error(`Codex thread ${id} is not attached`); return state; }
|
|
585
|
+
#disableMethod(method) { if (method === "turn/start")
|
|
586
|
+
this.#operations.messaging = false;
|
|
587
|
+
else if (method === "turn/interrupt")
|
|
588
|
+
this.#operations.interrupt = false; }
|
|
589
|
+
#retryDelay(attempts) { return Math.min(this.options.max_retry_ms ?? 5 * 60_000, (this.options.retry_base_ms ?? 30_000) * 2 ** Math.max(0, attempts - 1)); }
|
|
590
|
+
#diagnostic(method) { if (this.#unknown.has(method) || this.#unknown.size >= 100)
|
|
591
|
+
return; this.#unknown.add(method); this.registry.emit("log", `Codex app-server ignored unknown protocol event: ${method}`); }
|
|
592
|
+
}
|
|
593
|
+
//# sourceMappingURL=codex-app-server.js.map
|