@neta-art/cohub-cli 6.11.2 → 7.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/README.md +21 -0
- package/dist/commands/apps.js +13 -1
- package/dist/commands/runtime.d.ts +4 -0
- package/dist/commands/runtime.js +117 -0
- package/dist/commands/sandboxd-binary.js +2 -2
- package/dist/commands/spaces.js +3 -0
- package/dist/index.js +3 -3
- package/dist/runtime/archive-store.d.ts +44 -0
- package/dist/runtime/archive-store.js +353 -0
- package/dist/runtime/codex-usage.d.ts +9 -0
- package/dist/runtime/codex-usage.js +23 -0
- package/dist/runtime/connection.d.ts +16 -0
- package/dist/runtime/connection.js +318 -0
- package/dist/runtime/harness.d.ts +19 -0
- package/dist/runtime/harness.js +403 -0
- package/dist/runtime/json-rpc.d.ts +37 -0
- package/dist/runtime/json-rpc.js +165 -0
- package/dist/runtime/model-catalog.d.ts +6 -0
- package/dist/runtime/model-catalog.js +19 -0
- package/dist/runtime/native-archive.d.ts +16 -0
- package/dist/runtime/native-archive.js +88 -0
- package/dist/runtime/process-group.d.ts +3 -0
- package/dist/runtime/process-group.js +68 -0
- package/dist/runtime/session-store.d.ts +46 -0
- package/dist/runtime/session-store.js +273 -0
- package/package.json +11 -4
- package/dist/commands/sandbox.d.ts +0 -3
- package/dist/commands/sandbox.js +0 -175
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { JsonRpcProcess, record } from "./json-rpc.js";
|
|
2
|
+
import { codexModelCatalog } from "./model-catalog.js";
|
|
3
|
+
import { codexTokenTotals, codexUsage, subtractCodexTokens } from "./codex-usage.js";
|
|
4
|
+
import { downloadPublicImage } from "../safe-remote-image.js";
|
|
5
|
+
const runtimeEnvironment = (input) => ({ COHUB_SPACE_ID: input.spaceId, COHUB_SESSION_ID: input.sessionId, COHUB_TURN_ID: input.turnId });
|
|
6
|
+
const array = (value) => Array.isArray(value) ? value : [];
|
|
7
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
8
|
+
export function piContent(value) {
|
|
9
|
+
return array(value).flatMap((entry) => {
|
|
10
|
+
const block = record(entry);
|
|
11
|
+
if (block.type === "text")
|
|
12
|
+
return [{ type: "text", text: text(block.text) }];
|
|
13
|
+
if (block.type === "thinking")
|
|
14
|
+
return [{ type: "thinking", thinking: text(block.thinking), ...(typeof (block.thinkingSignature ?? block.signature) === "string" ? { signature: String(block.thinkingSignature ?? block.signature) } : {}) }];
|
|
15
|
+
if (block.type === "toolCall")
|
|
16
|
+
return [{ type: "tool_use", id: text(block.id), name: text(block.name), input: record(block.arguments) }];
|
|
17
|
+
if (block.type === "image")
|
|
18
|
+
return [{ type: "image", source: { type: "base64", data: text(block.data), media_type: text(block.mimeType) } }];
|
|
19
|
+
return [{ type: "text", text: JSON.stringify(block) }];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async function imageForPi(block) {
|
|
23
|
+
if (block.source.type === "base64")
|
|
24
|
+
return { type: "image", data: block.source.data, mimeType: block.source.media_type };
|
|
25
|
+
const image = await downloadPublicImage(block.source.url);
|
|
26
|
+
return { type: "image", data: Buffer.from(image.bytes).toString("base64"), mimeType: image.mimeType };
|
|
27
|
+
}
|
|
28
|
+
export function promptText(content) {
|
|
29
|
+
return content.filter((block) => block.type !== "image").map((block) => block.type === "text" ? block.text : JSON.stringify(block)).join("\n");
|
|
30
|
+
}
|
|
31
|
+
async function initializeCodex(rpc) {
|
|
32
|
+
await rpc.request("initialize", { clientInfo: { name: "cohub", title: "Cohub", version: "1" }, capabilities: { experimentalApi: true } });
|
|
33
|
+
rpc.write({ method: "initialized", params: {} });
|
|
34
|
+
}
|
|
35
|
+
/** Ask the native process to stop, then hard-close it if the request does not settle quickly. */
|
|
36
|
+
function createAbortEscalation(rpc, signal, interrupt) {
|
|
37
|
+
let timer = null;
|
|
38
|
+
const abort = () => {
|
|
39
|
+
interrupt();
|
|
40
|
+
timer ??= setTimeout(() => { void rpc.close().catch(() => undefined); }, 5000);
|
|
41
|
+
};
|
|
42
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
43
|
+
return {
|
|
44
|
+
abort,
|
|
45
|
+
clear: () => { signal.removeEventListener("abort", abort); if (timer)
|
|
46
|
+
clearTimeout(timer); },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Native files stay authoritative; archival failure only degrades cross-host resume. */
|
|
50
|
+
async function finishHarnessTurn(store, state, message, resume, turnId) {
|
|
51
|
+
const archive = await store.archive(state, turnId).catch((error) => { console.error("Native archive unavailable; local files retained:", error); return null; });
|
|
52
|
+
return { state, event: { type: "turn.end", message, resume, archive } };
|
|
53
|
+
}
|
|
54
|
+
export async function discoverHarnesses(harnesses, options, cwd) {
|
|
55
|
+
const models = [];
|
|
56
|
+
await Promise.all(harnesses.map(async (harness) => {
|
|
57
|
+
const rpc = new JsonRpcProcess(options[harness] || harness, harness === "pi" ? ["--mode", "rpc", "--no-session"] : ["app-server", "--listen", "stdio://"], cwd, harness);
|
|
58
|
+
try {
|
|
59
|
+
if (harness === "pi") {
|
|
60
|
+
const result = await rpc.request("get_available_models");
|
|
61
|
+
for (const value of array(result.models)) {
|
|
62
|
+
const model = record(value);
|
|
63
|
+
if (model.id && model.provider)
|
|
64
|
+
models.push({ harness, id: text(model.id), provider: text(model.provider), name: text(model.name) || text(model.id) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
await initializeCodex(rpc);
|
|
69
|
+
const config = await rpc.request("config/read", { includeLayers: false, cwd });
|
|
70
|
+
const entries = [];
|
|
71
|
+
const cursors = new Set();
|
|
72
|
+
let cursor = null;
|
|
73
|
+
do {
|
|
74
|
+
const result = await rpc.request("model/list", { cursor, limit: 100 });
|
|
75
|
+
entries.push(...array(result.data));
|
|
76
|
+
cursor = typeof result.nextCursor === "string" ? result.nextCursor : null;
|
|
77
|
+
if (entries.length > 2000 || cursor && cursors.has(cursor))
|
|
78
|
+
throw new Error("Invalid model catalog pagination");
|
|
79
|
+
if (cursor)
|
|
80
|
+
cursors.add(cursor);
|
|
81
|
+
} while (cursor);
|
|
82
|
+
models.push(...codexModelCatalog(config, entries));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await rpc.close();
|
|
87
|
+
}
|
|
88
|
+
}));
|
|
89
|
+
return { harnesses, models };
|
|
90
|
+
}
|
|
91
|
+
export async function executePi(input, options, cwd, store, emit, signal) {
|
|
92
|
+
if (input.accessMode === "read_only")
|
|
93
|
+
throw new Error("Pi cannot enforce read-only access; select Cohub or Codex");
|
|
94
|
+
signal.throwIfAborted();
|
|
95
|
+
const { state, resume } = await store.prepare(input, cwd, signal);
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
const rpc = new JsonRpcProcess(options.pi || "pi", ["--mode", "rpc", "--session", state.path], cwd, "pi", runtimeEnvironment(input));
|
|
98
|
+
let ordinal = -1;
|
|
99
|
+
let currentContent = [];
|
|
100
|
+
let last = { ordinal: 0, content: [] };
|
|
101
|
+
const abortEscalation = createAbortEscalation(rpc, signal, () => { void rpc.request("abort").catch(() => undefined); });
|
|
102
|
+
try {
|
|
103
|
+
if (input.model) {
|
|
104
|
+
let provider = input.provider;
|
|
105
|
+
if (!provider) {
|
|
106
|
+
const catalog = await rpc.request("get_available_models");
|
|
107
|
+
const matches = array(catalog.models).map(record).filter((model) => model.id === input.model);
|
|
108
|
+
if (matches.length !== 1)
|
|
109
|
+
throw new Error("Select a provider for this local model");
|
|
110
|
+
provider = text(matches[0]?.provider);
|
|
111
|
+
}
|
|
112
|
+
await rpc.request("set_model", { provider, modelId: input.model });
|
|
113
|
+
}
|
|
114
|
+
if (input.thinkingLevel)
|
|
115
|
+
await rpc.request("set_thinking_level", { level: input.thinkingLevel });
|
|
116
|
+
const stateResult = await rpc.request("get_state");
|
|
117
|
+
state.nativeSessionId = text(stateResult.sessionId) || state.nativeSessionId;
|
|
118
|
+
const model = record(stateResult.model);
|
|
119
|
+
const content = input.messages.flatMap((message) => message.content);
|
|
120
|
+
const images = await Promise.all(content.flatMap((block) => block.type === "image" ? [imageForPi(block)] : []));
|
|
121
|
+
signal.throwIfAborted();
|
|
122
|
+
await store.started(state, input.turnId);
|
|
123
|
+
await new Promise((resolve, reject) => {
|
|
124
|
+
const offFailure = rpc.onFailure(reject);
|
|
125
|
+
const off = rpc.onEvent((event) => {
|
|
126
|
+
try {
|
|
127
|
+
if (event.type === "extension_ui_request") {
|
|
128
|
+
if (["confirm", "select", "input", "editor"].includes(text(event.method)))
|
|
129
|
+
rpc.write({ type: "extension_ui_response", id: event.id, cancelled: true });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (event.type === "message_start" && record(event.message).role === "assistant") {
|
|
133
|
+
ordinal++;
|
|
134
|
+
currentContent = [];
|
|
135
|
+
emit({ type: "message.start", ordinal });
|
|
136
|
+
}
|
|
137
|
+
if (event.type === "message_end" && record(event.message).role === "assistant") {
|
|
138
|
+
currentContent = piContent(record(event.message).content);
|
|
139
|
+
emit({ type: "content.replace", ordinal, content: currentContent });
|
|
140
|
+
}
|
|
141
|
+
if (event.type === "tool_execution_start") {
|
|
142
|
+
const id = text(event.toolCallId);
|
|
143
|
+
if (!currentContent.some((block) => block.type === "tool_use" && block.id === id))
|
|
144
|
+
currentContent.push({ type: "tool_use", id, name: text(event.toolName), input: record(event.args), _meta: { toolStatus: "running" } });
|
|
145
|
+
emit({ type: "content.replace", ordinal, content: [...currentContent] });
|
|
146
|
+
}
|
|
147
|
+
if (event.type === "tool_execution_update" || event.type === "tool_execution_end") {
|
|
148
|
+
const id = text(event.toolCallId);
|
|
149
|
+
const rawResult = event.type === "tool_execution_end" ? event.result : event.partialResult;
|
|
150
|
+
const result = record(rawResult);
|
|
151
|
+
const resultContent = typeof rawResult === "string" ? rawResult : piContent(result.content);
|
|
152
|
+
currentContent = currentContent.filter((block) => block.type !== "tool_result" || block.tool_use_id !== id);
|
|
153
|
+
currentContent.push({ type: "tool_result", tool_use_id: id, content: resultContent, is_error: Boolean(event.isError), _meta: { toolStatus: event.type === "tool_execution_end" ? "done" : "running" } });
|
|
154
|
+
emit({ type: "content.replace", ordinal, content: [...currentContent] });
|
|
155
|
+
}
|
|
156
|
+
if (event.type === "message_update") {
|
|
157
|
+
const delta = record(event.assistantMessageEvent);
|
|
158
|
+
if (delta.type === "text_delta" || delta.type === "thinking_delta")
|
|
159
|
+
emit({ type: "text.delta", ordinal, index: Number(delta.contentIndex ?? 0), kind: delta.type === "text_delta" ? "text" : "thinking", delta: text(delta.delta) });
|
|
160
|
+
}
|
|
161
|
+
if (event.type === "turn_end") {
|
|
162
|
+
const message = record(event.message);
|
|
163
|
+
const content = piContent(message.content);
|
|
164
|
+
for (const resultValue of array(event.toolResults)) {
|
|
165
|
+
const result = record(resultValue);
|
|
166
|
+
content.push({ type: "tool_result", tool_use_id: text(result.toolCallId), content: typeof result.content === "string" ? result.content : piContent(result.content), is_error: Boolean(result.isError) });
|
|
167
|
+
}
|
|
168
|
+
last = { ordinal: Math.max(0, ordinal), content, provider: text(message.provider) || text(model.provider), model: text(message.model) || text(model.id), usage: message.usage, stopReason: text(message.stopReason) === "toolUse" ? "tool_use" : text(message.stopReason) || "stop", errorMessage: text(message.errorMessage) || null };
|
|
169
|
+
emit({ type: "content.replace", ordinal: last.ordinal, content });
|
|
170
|
+
if (last.stopReason === "tool_use")
|
|
171
|
+
emit({ type: "message.commit", message: last });
|
|
172
|
+
}
|
|
173
|
+
if (event.type === "agent_settled" || event.type === "agent_end" && event.willRetry === undefined) {
|
|
174
|
+
off();
|
|
175
|
+
offFailure();
|
|
176
|
+
resolve();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
off();
|
|
181
|
+
offFailure();
|
|
182
|
+
reject(error);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
void rpc.request("prompt", { message: promptText(content), images }).catch((error) => { off(); offFailure(); reject(error); });
|
|
186
|
+
});
|
|
187
|
+
await rpc.request("get_state");
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (!state.pendingTurnId)
|
|
191
|
+
throw error;
|
|
192
|
+
last = { ...last, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : error instanceof Error ? error.message : String(error) };
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
abortEscalation.clear();
|
|
196
|
+
await rpc.close();
|
|
197
|
+
}
|
|
198
|
+
if (signal.aborted)
|
|
199
|
+
last = { ...last, stopReason: "aborted" };
|
|
200
|
+
return finishHarnessTurn(store, state, last, resume, input.turnId);
|
|
201
|
+
}
|
|
202
|
+
export function codexItemContent(item) {
|
|
203
|
+
if (item.type === "agentMessage" || item.type === "plan")
|
|
204
|
+
return [{ type: "text", text: text(item.text) }];
|
|
205
|
+
if (item.type === "reasoning")
|
|
206
|
+
return [{ type: "thinking", thinking: [...array(item.summary), ...array(item.content)].map(text).join("\n") }];
|
|
207
|
+
if (item.type === "userMessage")
|
|
208
|
+
return [];
|
|
209
|
+
if (item.type === "contextCompaction")
|
|
210
|
+
return [{ type: "system_note", note_type: "compacted", text: "Context compacted" }];
|
|
211
|
+
const id = text(item.id);
|
|
212
|
+
const name = item.type === "commandExecution" ? "bash" : item.type === "fileChange" ? "apply_patch" : text(item.tool) || text(item.type);
|
|
213
|
+
const input = item.type === "commandExecution" ? { command: item.command, cwd: item.cwd } : item.type === "fileChange" ? { changes: item.changes } : record(item.arguments);
|
|
214
|
+
return [
|
|
215
|
+
{ type: "tool_use", id, name, input },
|
|
216
|
+
{ type: "tool_result", tool_use_id: id, content: typeof item.aggregatedOutput === "string" ? item.aggregatedOutput : JSON.stringify(item.result ?? item.error ?? item.changes ?? item), is_error: item.status === "failed" || typeof item.exitCode === "number" && item.exitCode !== 0 },
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
export async function executeCodex(input, options, cwd, store, emit, signal) {
|
|
220
|
+
signal.throwIfAborted();
|
|
221
|
+
const { state, resume } = await store.prepare(input, cwd, signal);
|
|
222
|
+
signal.throwIfAborted();
|
|
223
|
+
const rpc = new JsonRpcProcess(options.codex || "codex", ["app-server", "--listen", "stdio://"], cwd, "codex", runtimeEnvironment(input));
|
|
224
|
+
let nativeTurnId = null;
|
|
225
|
+
let ordinal = -1;
|
|
226
|
+
const ordinals = new Map();
|
|
227
|
+
const items = new Map();
|
|
228
|
+
const outputs = new Map();
|
|
229
|
+
const completedItems = new Set();
|
|
230
|
+
let pending = null;
|
|
231
|
+
let final = { ordinal: 0, content: [] };
|
|
232
|
+
let usage;
|
|
233
|
+
let usageBaseline = state.codexTokenTotals;
|
|
234
|
+
const latestMessage = () => {
|
|
235
|
+
if (pending && pending.ordinal >= ordinal)
|
|
236
|
+
return pending;
|
|
237
|
+
const item = [...items.entries()].find(([id]) => ordinals.get(id) === ordinal)?.[1];
|
|
238
|
+
return { ordinal: Math.max(0, ordinal), content: item ? codexItemContent(item) : [] };
|
|
239
|
+
};
|
|
240
|
+
const abortEscalation = createAbortEscalation(rpc, signal, () => {
|
|
241
|
+
if (nativeTurnId)
|
|
242
|
+
void rpc.request("turn/interrupt", { threadId: state.nativeSessionId, turnId: nativeTurnId }).catch(() => undefined);
|
|
243
|
+
});
|
|
244
|
+
try {
|
|
245
|
+
await initializeCodex(rpc);
|
|
246
|
+
const threadOptions = {
|
|
247
|
+
cwd,
|
|
248
|
+
...(input.model ? { model: input.model } : {}),
|
|
249
|
+
...(input.provider && input.provider !== "codex" ? { modelProvider: input.provider } : {}),
|
|
250
|
+
...(input.accessMode === "read_only" ? { sandbox: "read-only" } : {}),
|
|
251
|
+
};
|
|
252
|
+
const opened = resume === "native"
|
|
253
|
+
? await rpc.request("thread/resume", { ...threadOptions, threadId: state.nativeSessionId, excludeTurns: true })
|
|
254
|
+
: resume === "restored"
|
|
255
|
+
? await rpc.request("thread/fork", { ...threadOptions, threadId: state.nativeSessionId, path: state.path, excludeTurns: true })
|
|
256
|
+
: await rpc.request("thread/start", threadOptions);
|
|
257
|
+
const thread = record(opened.thread);
|
|
258
|
+
if (typeof thread.id !== "string" || typeof thread.path !== "string")
|
|
259
|
+
throw new Error("Codex did not provide a durable native thread");
|
|
260
|
+
state.nativeSessionId = thread.id;
|
|
261
|
+
state.path = thread.path;
|
|
262
|
+
const provider = text(opened.modelProvider) || "codex";
|
|
263
|
+
const model = text(opened.model) || input.model;
|
|
264
|
+
const content = await Promise.all(input.messages.flatMap((message) => message.content).map(async (block) => {
|
|
265
|
+
if (block.type === "image") {
|
|
266
|
+
const image = await imageForPi(block);
|
|
267
|
+
return { type: "image", url: `data:${image.mimeType};base64,${image.data}` };
|
|
268
|
+
}
|
|
269
|
+
return { type: "text", text: block.type === "text" ? block.text : JSON.stringify(block), text_elements: [] };
|
|
270
|
+
}));
|
|
271
|
+
signal.throwIfAborted();
|
|
272
|
+
await store.started(state, input.turnId);
|
|
273
|
+
await new Promise((resolve, reject) => {
|
|
274
|
+
const offFailure = rpc.onFailure(reject);
|
|
275
|
+
const off = rpc.onEvent((event) => {
|
|
276
|
+
try {
|
|
277
|
+
const method = text(event.method);
|
|
278
|
+
const params = record(event.params);
|
|
279
|
+
if (event.id != null) {
|
|
280
|
+
// Keep native approval policy. An unattended client never grants an escalation.
|
|
281
|
+
rpc.write({ id: event.id, error: { code: -32000, message: "Approval requires an interactive local client" } });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (params.threadId !== state.nativeSessionId)
|
|
285
|
+
return;
|
|
286
|
+
if (method === "turn/started") {
|
|
287
|
+
nativeTurnId = text(record(params.turn).id);
|
|
288
|
+
if (signal.aborted)
|
|
289
|
+
abortEscalation.abort();
|
|
290
|
+
}
|
|
291
|
+
if (method === "thread/tokenUsage/updated" && nativeTurnId && params.turnId === nativeTurnId) {
|
|
292
|
+
const nativeUsage = record(params.tokenUsage);
|
|
293
|
+
const total = codexTokenTotals(nativeUsage.total);
|
|
294
|
+
usageBaseline ??= subtractCodexTokens(total, codexTokenTotals(nativeUsage.last));
|
|
295
|
+
state.codexTokenTotals = total;
|
|
296
|
+
usage = codexUsage(subtractCodexTokens(total, usageBaseline));
|
|
297
|
+
}
|
|
298
|
+
if (method === "item/started") {
|
|
299
|
+
const item = record(params.item);
|
|
300
|
+
if (item.type === "userMessage" || completedItems.has(text(item.id)))
|
|
301
|
+
return;
|
|
302
|
+
const existingOrdinal = ordinals.get(text(item.id));
|
|
303
|
+
if (pending && (existingOrdinal == null || existingOrdinal > pending.ordinal)) {
|
|
304
|
+
emit({ type: "message.commit", message: pending });
|
|
305
|
+
pending = null;
|
|
306
|
+
}
|
|
307
|
+
if (existingOrdinal != null) {
|
|
308
|
+
items.set(text(item.id), item);
|
|
309
|
+
if (item.type === "agentMessage" && text(item.text))
|
|
310
|
+
emit({ type: "content.replace", ordinal: existingOrdinal, content: codexItemContent(item) });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
ordinal++;
|
|
314
|
+
ordinals.set(text(item.id), ordinal);
|
|
315
|
+
items.set(text(item.id), item);
|
|
316
|
+
emit({ type: "message.start", ordinal });
|
|
317
|
+
if (!["agentMessage", "reasoning", "plan"].includes(text(item.type)))
|
|
318
|
+
emit({ type: "content.replace", ordinal, content: codexItemContent(item).filter((block) => block.type !== "tool_result") });
|
|
319
|
+
}
|
|
320
|
+
if (method === "item/agentMessage/delta" || method === "item/reasoning/textDelta" || method === "item/reasoning/summaryTextDelta") {
|
|
321
|
+
const id = text(params.itemId);
|
|
322
|
+
if (completedItems.has(id))
|
|
323
|
+
return;
|
|
324
|
+
const itemOrdinal = ordinals.get(id);
|
|
325
|
+
const item = items.get(id);
|
|
326
|
+
if (item) {
|
|
327
|
+
if (method === "item/agentMessage/delta")
|
|
328
|
+
item.text = text(item.text) + text(params.delta);
|
|
329
|
+
else {
|
|
330
|
+
const key = method.includes("summary") ? "summary" : "content";
|
|
331
|
+
item[key] = [array(item[key]).map(text).join("\n") + text(params.delta)];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (itemOrdinal != null)
|
|
335
|
+
emit({ type: "text.delta", ordinal: itemOrdinal, index: 0, kind: method === "item/agentMessage/delta" ? "text" : "thinking", delta: text(params.delta) });
|
|
336
|
+
}
|
|
337
|
+
if (method === "item/commandExecution/outputDelta") {
|
|
338
|
+
const id = text(params.itemId);
|
|
339
|
+
const item = items.get(id);
|
|
340
|
+
const itemOrdinal = ordinals.get(id);
|
|
341
|
+
if (item && itemOrdinal != null) {
|
|
342
|
+
const output = (outputs.get(id) ?? "") + text(params.delta);
|
|
343
|
+
outputs.set(id, output);
|
|
344
|
+
item.aggregatedOutput = output;
|
|
345
|
+
emit({ type: "content.replace", ordinal: itemOrdinal, content: codexItemContent(item) });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (method === "item/completed") {
|
|
349
|
+
const item = record(params.item);
|
|
350
|
+
if (item.type === "userMessage" || completedItems.has(text(item.id)))
|
|
351
|
+
return;
|
|
352
|
+
completedItems.add(text(item.id));
|
|
353
|
+
const itemOrdinal = ordinals.get(text(item.id)) ?? ++ordinal;
|
|
354
|
+
if (!ordinals.has(text(item.id))) {
|
|
355
|
+
ordinals.set(text(item.id), itemOrdinal);
|
|
356
|
+
emit({ type: "message.start", ordinal: itemOrdinal });
|
|
357
|
+
}
|
|
358
|
+
items.set(text(item.id), item);
|
|
359
|
+
const message = { ordinal: itemOrdinal, content: codexItemContent(item), provider, model, stopReason: "stop" };
|
|
360
|
+
emit({ type: "content.replace", ordinal: itemOrdinal, content: message.content });
|
|
361
|
+
if (pending && pending.ordinal > itemOrdinal)
|
|
362
|
+
emit({ type: "message.commit", message });
|
|
363
|
+
else {
|
|
364
|
+
if (pending && pending.ordinal !== itemOrdinal)
|
|
365
|
+
emit({ type: "message.commit", message: pending });
|
|
366
|
+
pending = message;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (method === "turn/completed") {
|
|
370
|
+
const turn = record(params.turn);
|
|
371
|
+
final = { ...latestMessage(), provider, model, stopReason: turn.status === "interrupted" ? "aborted" : turn.status === "failed" ? "error" : "stop", errorMessage: text(record(turn.error).message) || null };
|
|
372
|
+
off();
|
|
373
|
+
offFailure();
|
|
374
|
+
resolve();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
off();
|
|
379
|
+
offFailure();
|
|
380
|
+
reject(error);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
void rpc.request("turn/start", { threadId: state.nativeSessionId, clientUserMessageId: input.userMessageId, input: content, ...(input.thinkingLevel ? { effort: input.thinkingLevel } : {}) })
|
|
384
|
+
.then((result) => { nativeTurnId = text(record(result.turn).id); if (signal.aborted)
|
|
385
|
+
abortEscalation.abort(); })
|
|
386
|
+
.catch((error) => { off(); offFailure(); reject(error); });
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
if (!state.pendingTurnId)
|
|
391
|
+
throw error;
|
|
392
|
+
final = { ...latestMessage(), stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : error instanceof Error ? error.message : String(error) };
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
abortEscalation.clear();
|
|
396
|
+
await rpc.close();
|
|
397
|
+
}
|
|
398
|
+
if (signal.aborted)
|
|
399
|
+
final = { ...final, stopReason: "aborted" };
|
|
400
|
+
if (usage)
|
|
401
|
+
final = { ...final, usage };
|
|
402
|
+
return finishHarnessTurn(store, state, final, resume, input.turnId);
|
|
403
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type JsonRecord = Record<string, unknown>;
|
|
2
|
+
export declare const record: (value: unknown) => JsonRecord;
|
|
3
|
+
/** LF framing without rescanning the accumulated record on every pipe chunk. */
|
|
4
|
+
export declare class JsonLineDecoder {
|
|
5
|
+
private onValue;
|
|
6
|
+
private maxBytes;
|
|
7
|
+
private decoder;
|
|
8
|
+
private fragments;
|
|
9
|
+
private bytes;
|
|
10
|
+
constructor(onValue: (value: JsonRecord) => void, maxBytes?: number);
|
|
11
|
+
private append;
|
|
12
|
+
private emit;
|
|
13
|
+
push(chunk: Buffer): void;
|
|
14
|
+
end(): void;
|
|
15
|
+
}
|
|
16
|
+
export declare function harnessEnvironment(): NodeJS.ProcessEnv;
|
|
17
|
+
export declare class JsonRpcProcess {
|
|
18
|
+
private mode;
|
|
19
|
+
private child;
|
|
20
|
+
private pending;
|
|
21
|
+
private listeners;
|
|
22
|
+
private failureListeners;
|
|
23
|
+
private failure;
|
|
24
|
+
private nextId;
|
|
25
|
+
private stderr;
|
|
26
|
+
private closed;
|
|
27
|
+
private closing;
|
|
28
|
+
constructor(binary: string, args: string[], cwd: string, mode: "pi" | "codex", context?: Record<string, string>);
|
|
29
|
+
private fail;
|
|
30
|
+
private receive;
|
|
31
|
+
write(value: unknown): void;
|
|
32
|
+
request(method: string, params?: JsonRecord, timeoutMs?: number): Promise<JsonRecord>;
|
|
33
|
+
onEvent(listener: (event: JsonRecord) => void): () => boolean;
|
|
34
|
+
onFailure(listener: (error: Error) => void): () => boolean;
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
private closeProcessGroup;
|
|
37
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { RUNTIME_MAX_FRAME_BYTES } from "@neta-art/cohub";
|
|
4
|
+
import { stopProcessGroup } from "./process-group.js";
|
|
5
|
+
export const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
6
|
+
/** LF framing without rescanning the accumulated record on every pipe chunk. */
|
|
7
|
+
export class JsonLineDecoder {
|
|
8
|
+
onValue;
|
|
9
|
+
maxBytes;
|
|
10
|
+
decoder = new StringDecoder("utf8");
|
|
11
|
+
fragments = [];
|
|
12
|
+
bytes = 0;
|
|
13
|
+
constructor(onValue, maxBytes = RUNTIME_MAX_FRAME_BYTES) {
|
|
14
|
+
this.onValue = onValue;
|
|
15
|
+
this.maxBytes = maxBytes;
|
|
16
|
+
}
|
|
17
|
+
append(fragment) {
|
|
18
|
+
this.bytes += Buffer.byteLength(fragment);
|
|
19
|
+
if (this.bytes > this.maxBytes)
|
|
20
|
+
throw new Error("RPC frame is too large");
|
|
21
|
+
this.fragments.push(fragment);
|
|
22
|
+
}
|
|
23
|
+
emit() {
|
|
24
|
+
const line = this.fragments.join("");
|
|
25
|
+
this.fragments = [];
|
|
26
|
+
this.bytes = 0;
|
|
27
|
+
if (!line.trim())
|
|
28
|
+
return;
|
|
29
|
+
const value = JSON.parse(line);
|
|
30
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
31
|
+
throw new Error("RPC frame must be an object");
|
|
32
|
+
this.onValue(value);
|
|
33
|
+
}
|
|
34
|
+
push(chunk) {
|
|
35
|
+
const text = this.decoder.write(chunk);
|
|
36
|
+
let start = 0;
|
|
37
|
+
for (let end = text.indexOf("\n"); end >= 0; end = text.indexOf("\n", start)) {
|
|
38
|
+
this.append(text.slice(start, end));
|
|
39
|
+
this.emit();
|
|
40
|
+
start = end + 1;
|
|
41
|
+
}
|
|
42
|
+
if (start < text.length)
|
|
43
|
+
this.append(text.slice(start));
|
|
44
|
+
}
|
|
45
|
+
end() { this.append(this.decoder.end()); this.emit(); }
|
|
46
|
+
}
|
|
47
|
+
export function harnessEnvironment() {
|
|
48
|
+
return Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("COHUB_") && !["WORKER_SECRET", "DATABASE_URL", "REDIS_URL"].includes(key)));
|
|
49
|
+
}
|
|
50
|
+
export class JsonRpcProcess {
|
|
51
|
+
mode;
|
|
52
|
+
child;
|
|
53
|
+
pending = new Map();
|
|
54
|
+
listeners = new Set();
|
|
55
|
+
failureListeners = new Set();
|
|
56
|
+
failure = null;
|
|
57
|
+
nextId = 0;
|
|
58
|
+
stderr = "";
|
|
59
|
+
closed;
|
|
60
|
+
closing = null;
|
|
61
|
+
constructor(binary, args, cwd, mode, context = {}) {
|
|
62
|
+
this.mode = mode;
|
|
63
|
+
this.child = spawn(binary, args, { cwd, env: { ...harnessEnvironment(), ...context }, stdio: "pipe", detached: process.platform !== "win32" });
|
|
64
|
+
this.closed = new Promise((resolve) => this.child.once("close", () => resolve()));
|
|
65
|
+
const decoder = new JsonLineDecoder((value) => this.receive(value));
|
|
66
|
+
this.child.stdout.on("data", (chunk) => { try {
|
|
67
|
+
decoder.push(chunk);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
this.fail(error);
|
|
71
|
+
} });
|
|
72
|
+
this.child.stdout.on("end", () => { try {
|
|
73
|
+
decoder.end();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
this.fail(error);
|
|
77
|
+
} });
|
|
78
|
+
this.child.stderr.on("data", (chunk) => { this.stderr = (this.stderr + chunk.toString()).slice(-8192); });
|
|
79
|
+
this.child.on("error", (error) => this.fail(error));
|
|
80
|
+
this.child.stdin.on("error", (error) => this.fail(error));
|
|
81
|
+
this.child.once("close", (code) => this.fail(new Error(`${binary} exited (${code}): ${this.stderr}`)));
|
|
82
|
+
}
|
|
83
|
+
fail(value) {
|
|
84
|
+
if (this.failure)
|
|
85
|
+
return;
|
|
86
|
+
this.failure = value instanceof Error ? value : new Error(String(value));
|
|
87
|
+
for (const pending of this.pending.values()) {
|
|
88
|
+
clearTimeout(pending.timer);
|
|
89
|
+
pending.reject(this.failure);
|
|
90
|
+
}
|
|
91
|
+
this.pending.clear();
|
|
92
|
+
for (const listener of this.failureListeners)
|
|
93
|
+
listener(this.failure);
|
|
94
|
+
}
|
|
95
|
+
receive(value) {
|
|
96
|
+
const key = value.id == null ? null : String(value.id);
|
|
97
|
+
const response = this.mode === "pi" ? value.type === "response" : "result" in value || "error" in value;
|
|
98
|
+
if (key && response) {
|
|
99
|
+
const pending = this.pending.get(key);
|
|
100
|
+
if (!pending)
|
|
101
|
+
return;
|
|
102
|
+
this.pending.delete(key);
|
|
103
|
+
clearTimeout(pending.timer);
|
|
104
|
+
if (value.error || value.success === false)
|
|
105
|
+
pending.reject(new Error(typeof value.error === "string" ? value.error : String(record(value.error).message ?? "RPC request failed")));
|
|
106
|
+
else
|
|
107
|
+
pending.resolve(record(this.mode === "pi" ? value.data : value.result));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (const listener of this.listeners)
|
|
111
|
+
listener(value);
|
|
112
|
+
}
|
|
113
|
+
write(value) {
|
|
114
|
+
if (this.failure)
|
|
115
|
+
throw this.failure;
|
|
116
|
+
const data = `${JSON.stringify(value)}\n`;
|
|
117
|
+
if (this.child.stdin.writableLength + Buffer.byteLength(data) > RUNTIME_MAX_FRAME_BYTES)
|
|
118
|
+
throw new Error("RPC input backpressure limit exceeded");
|
|
119
|
+
this.child.stdin.write(data);
|
|
120
|
+
}
|
|
121
|
+
request(method, params = {}, timeoutMs = 30_000) {
|
|
122
|
+
if (this.failure)
|
|
123
|
+
return Promise.reject(this.failure);
|
|
124
|
+
const id = String(++this.nextId);
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method} timed out`)); }, timeoutMs);
|
|
127
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
128
|
+
try {
|
|
129
|
+
this.write(this.mode === "pi" ? { ...params, id, type: method } : { id, method, params });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
this.pending.delete(id);
|
|
134
|
+
reject(error);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
onEvent(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
139
|
+
onFailure(listener) {
|
|
140
|
+
this.failureListeners.add(listener);
|
|
141
|
+
if (this.failure)
|
|
142
|
+
queueMicrotask(() => { if (this.failure && this.failureListeners.has(listener))
|
|
143
|
+
listener(this.failure); });
|
|
144
|
+
return () => this.failureListeners.delete(listener);
|
|
145
|
+
}
|
|
146
|
+
close() {
|
|
147
|
+
this.closing ??= this.closeProcessGroup();
|
|
148
|
+
return this.closing;
|
|
149
|
+
}
|
|
150
|
+
async closeProcessGroup() {
|
|
151
|
+
try {
|
|
152
|
+
if (this.child.pid)
|
|
153
|
+
await stopProcessGroup(this.child.pid);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
this.child.stdin.destroy();
|
|
157
|
+
this.child.stdout.destroy();
|
|
158
|
+
this.child.stderr.destroy();
|
|
159
|
+
this.fail(new Error("RPC process closed"));
|
|
160
|
+
this.listeners.clear();
|
|
161
|
+
this.failureListeners.clear();
|
|
162
|
+
}
|
|
163
|
+
await this.closed;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { RuntimeCapabilities } from "@neta-art/cohub";
|
|
2
|
+
import { type JsonRecord } from "./json-rpc.js";
|
|
3
|
+
type Model = RuntimeCapabilities["models"][number];
|
|
4
|
+
/** Codex's built-in catalog is not authoritative for an arbitrary custom provider. */
|
|
5
|
+
export declare function codexModelCatalog(configResponse: JsonRecord, entries: unknown[]): Model[];
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { record } from "./json-rpc.js";
|
|
2
|
+
/** Codex's built-in catalog is not authoritative for an arbitrary custom provider. */
|
|
3
|
+
export function codexModelCatalog(configResponse, entries) {
|
|
4
|
+
const config = record(configResponse.config);
|
|
5
|
+
const provider = typeof config.model_provider === "string" ? config.model_provider : "openai";
|
|
6
|
+
const configuredModel = typeof config.model === "string" ? config.model : null;
|
|
7
|
+
const customCatalog = typeof config.model_catalog_json === "string";
|
|
8
|
+
const catalog = provider !== "openai" && !customCatalog ? [] : entries;
|
|
9
|
+
const models = catalog.flatMap((value) => {
|
|
10
|
+
const model = record(value);
|
|
11
|
+
const id = typeof model.model === "string" ? model.model : typeof model.id === "string" ? model.id : null;
|
|
12
|
+
if (!id || model.hidden)
|
|
13
|
+
return [];
|
|
14
|
+
return [{ harness: "codex", provider, id, name: typeof model.displayName === "string" ? model.displayName : id }];
|
|
15
|
+
});
|
|
16
|
+
if (configuredModel && !models.some((model) => model.id === configuredModel))
|
|
17
|
+
models.unshift({ harness: "codex", provider, id: configuredModel, name: configuredModel });
|
|
18
|
+
return models;
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CodexTokenTotals } from "./codex-usage.js";
|
|
2
|
+
export declare function readCodexArchiveTotals(path: string): Promise<CodexTokenTotals | undefined>;
|
|
3
|
+
/** Change only the header of a working copy. Raw archive bytes remain untouched. */
|
|
4
|
+
export declare function importNativeArchive(input: {
|
|
5
|
+
source: string;
|
|
6
|
+
target: string;
|
|
7
|
+
harness: "pi" | "codex";
|
|
8
|
+
nativeSessionId: string;
|
|
9
|
+
id: string;
|
|
10
|
+
cwd: string;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
checksum: string;
|
|
14
|
+
nativeSessionId: string;
|
|
15
|
+
codexTokenTotals: CodexTokenTotals | undefined;
|
|
16
|
+
}>;
|