@neta-art/cohub-cli 6.12.0 → 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.
@@ -0,0 +1,23 @@
1
+ import { record } from "./json-rpc.js";
2
+ const fields = ["inputTokens", "outputTokens", "cachedInputTokens", "cacheWriteInputTokens", "totalTokens"];
3
+ const tokens = (value) => typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
4
+ export const codexTokenTotals = (value) => {
5
+ const input = record(value);
6
+ return Object.fromEntries(fields.map((key) => [key, tokens(input[key])]));
7
+ };
8
+ export const subtractCodexTokens = (total, base) => Object.fromEntries(fields.map((key) => [key, Math.max(0, total[key] - base[key])]));
9
+ export function codexUsage(total) {
10
+ return { input: Math.max(0, total.inputTokens - total.cachedInputTokens - total.cacheWriteInputTokens), output: total.outputTokens, cacheRead: total.cachedInputTokens, cacheWrite: total.cacheWriteInputTokens, totalTokens: total.totalTokens };
11
+ }
12
+ /** Seed portable imports from the original native counters, not a prior turn's `last`. */
13
+ export function codexArchiveTotals(records) {
14
+ for (let i = records.length - 1; i >= 0; i--) {
15
+ const entry = records[i];
16
+ if (entry?.type !== "token_usage_record")
17
+ continue;
18
+ const value = record(record(entry.payload).thread_token_usage);
19
+ if (typeof value.total_tokens !== "number")
20
+ continue;
21
+ return codexTokenTotals({ inputTokens: value.input_tokens, outputTokens: value.output_tokens, cachedInputTokens: value.cached_input_tokens, cacheWriteInputTokens: value.cache_write_input_tokens, totalTokens: value.total_tokens });
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ import { type RuntimeCapabilities } from "@neta-art/cohub";
2
+ import { type HarnessOptions } from "./harness.js";
3
+ import { type RuntimeSessionStore } from "./session-store.js";
4
+ export type RuntimeConnectionOptions = {
5
+ spaceId: string;
6
+ cwd: string;
7
+ url: string;
8
+ capabilities: RuntimeCapabilities;
9
+ harnesses: HarnessOptions;
10
+ token: () => Promise<string>;
11
+ signal: AbortSignal;
12
+ store: RuntimeSessionStore;
13
+ onReady: () => void;
14
+ leaseConflictTimeoutMs?: number;
15
+ };
16
+ export declare function serveRuntime(options: RuntimeConnectionOptions): Promise<void>;
@@ -0,0 +1,318 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { RUNTIME_MAX_FRAME_BYTES, RUNTIME_PROTOCOL_VERSION, runtimeCommandSchema, runtimeReadySchema } from "@neta-art/cohub";
3
+ import { executeCodex, executePi } from "./harness.js";
4
+ import { ProcessCleanupUncertainError } from "./process-group.js";
5
+ import { ContextRequiredError } from "./session-store.js";
6
+ export async function serveRuntime(options) {
7
+ let backoff = 500;
8
+ let conflictSince = null;
9
+ const uploads = new AbortController();
10
+ const uploadSignal = AbortSignal.any([options.signal, uploads.signal]);
11
+ const flush = () => options.store.flushArchives(uploadSignal).catch((error) => {
12
+ if (!uploadSignal.aborted)
13
+ console.error("Archive pending / 归档待重试:", error);
14
+ });
15
+ const timer = setInterval(() => { void flush(); }, 10_000);
16
+ void flush();
17
+ try {
18
+ while (!options.signal.aborted) {
19
+ const outcome = await connect({ ...options, onReady: () => { backoff = 500; conflictSince = null; options.onReady(); void flush(); } });
20
+ if (options.signal.aborted)
21
+ return;
22
+ if (outcome === "fatal")
23
+ throw new Error("Runtime connection rejected / Runtime 连接被拒绝");
24
+ if (outcome === "conflict") {
25
+ conflictSince ??= Date.now();
26
+ if (Date.now() - conflictSince >= (options.leaseConflictTimeoutMs ?? 90_000))
27
+ throw new Error("Space is already connected to another Runtime / Space 已连接其他 Runtime");
28
+ }
29
+ await delay(backoff, undefined, { signal: options.signal }).catch(() => undefined);
30
+ backoff = Math.min(10_000, backoff * 2);
31
+ }
32
+ }
33
+ finally {
34
+ clearInterval(timer);
35
+ uploads.abort();
36
+ await flush();
37
+ }
38
+ }
39
+ async function connect(options) {
40
+ let token = await options.token();
41
+ const socket = new WebSocket(options.url);
42
+ const active = new Map();
43
+ const seen = new Set();
44
+ const disconnected = new AbortController();
45
+ const contexts = new Map();
46
+ let lastHeartbeat = Date.now();
47
+ let connectionId = null;
48
+ let fatal = false;
49
+ let conflict = false;
50
+ let readyTimer;
51
+ const send = (frame) => {
52
+ if (socket.readyState !== WebSocket.OPEN || socket.bufferedAmount > RUNTIME_MAX_FRAME_BYTES)
53
+ throw new Error("Runtime connection unavailable");
54
+ const data = JSON.stringify(frame);
55
+ if (Buffer.byteLength(data) > RUNTIME_MAX_FRAME_BYTES)
56
+ throw new Error("Runtime frame exceeds transfer limit");
57
+ socket.send(data);
58
+ };
59
+ const stop = () => { for (const execution of active.values())
60
+ execution.controller.abort(); socket.close(); };
61
+ options.signal.addEventListener("abort", stop, { once: true });
62
+ const heartbeat = setInterval(() => {
63
+ if (Date.now() - lastHeartbeat > 30_000)
64
+ stop();
65
+ else if (connectionId) {
66
+ try {
67
+ send({ type: "runtime.heartbeat" });
68
+ }
69
+ catch {
70
+ stop();
71
+ }
72
+ void options.token().then((next) => { if (next !== token) {
73
+ send({ type: "runtime.auth", token: next });
74
+ token = next;
75
+ } }).catch(stop);
76
+ }
77
+ }, 10_000);
78
+ const closed = new Promise((resolve) => {
79
+ socket.addEventListener("close", (event) => {
80
+ fatal = [4400, 4401, 4403].includes(event.code);
81
+ conflict = event.code === 4409;
82
+ disconnected.abort();
83
+ if (!options.signal.aborted)
84
+ console.error(`Runtime disconnected (${event.code}): ${event.reason}`);
85
+ for (const execution of active.values())
86
+ execution.controller.abort();
87
+ resolve();
88
+ }, { once: true });
89
+ });
90
+ socket.addEventListener("error", () => socket.close());
91
+ socket.addEventListener("open", () => {
92
+ try {
93
+ send({ type: "runtime.hello", version: RUNTIME_PROTOCOL_VERSION, spaceId: options.spaceId, token, capabilities: options.capabilities });
94
+ }
95
+ catch {
96
+ stop();
97
+ }
98
+ });
99
+ socket.addEventListener("message", (event) => {
100
+ void (async () => {
101
+ const raw = JSON.parse(String(event.data));
102
+ if (raw.type === "runtime.ready") {
103
+ const frame = runtimeReadySchema.parse(raw);
104
+ if (connectionId === frame.connectionId)
105
+ return;
106
+ if (connectionId)
107
+ throw new Error("Runtime connection identity changed");
108
+ connectionId = frame.connectionId;
109
+ clearTimeout(readyTimer);
110
+ options.onReady();
111
+ return;
112
+ }
113
+ if (!connectionId)
114
+ throw new Error("Runtime handshake is incomplete");
115
+ if (raw.type === "runtime.heartbeat") {
116
+ lastHeartbeat = Date.now();
117
+ return;
118
+ }
119
+ const frame = runtimeCommandSchema.parse(raw);
120
+ if (frame.type === "session.context") {
121
+ contexts.get(frame.requestId)?.(frame.context);
122
+ contexts.delete(frame.requestId);
123
+ return;
124
+ }
125
+ if (frame.type === "turn.abort") {
126
+ active.get(frame.requestId)?.controller.abort();
127
+ return;
128
+ }
129
+ if (frame.type === "turn.ack") {
130
+ const execution = active.get(frame.requestId);
131
+ if (!execution)
132
+ return;
133
+ try {
134
+ await execution.promise;
135
+ if (!execution.result)
136
+ throw new Error("Runtime result is not available");
137
+ await options.store.acknowledge(execution.result.state, frame.turnId, frame.revision);
138
+ }
139
+ catch (error) {
140
+ console.error("Runtime acknowledgement failed; result retained:", error);
141
+ active.delete(frame.requestId);
142
+ send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", message: "Local acknowledgement failed; result retained / 本地确认失败,结果已保留" } });
143
+ return;
144
+ }
145
+ active.delete(frame.requestId);
146
+ send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.acknowledged" } });
147
+ return;
148
+ }
149
+ if (frame.type === "turn.recover") {
150
+ const identity = frame.execution;
151
+ if (identity.spaceId !== options.spaceId)
152
+ throw new Error("Invalid Runtime target");
153
+ const previous = active.get(frame.requestId);
154
+ if (previous) {
155
+ if (previous.sessionId !== identity.sessionId || previous.turnId !== identity.turnId || previous.harness !== identity.harness)
156
+ throw new Error("Runtime recovery identity changed");
157
+ await previous.promise;
158
+ const saved = await options.store.recoverResult(identity);
159
+ if (saved)
160
+ for (const event of saved.events)
161
+ send({ type: "runtime.event", requestId: frame.requestId, event });
162
+ return;
163
+ }
164
+ const running = [...active].find(([, entry]) => entry.turnId === identity.turnId && entry.sessionId === identity.sessionId && entry.harness === identity.harness);
165
+ const recovery = { ...identity, controller: new AbortController(), promise: Promise.resolve() };
166
+ active.set(frame.requestId, recovery);
167
+ recovery.promise = (async () => {
168
+ try {
169
+ if (running) {
170
+ running[1].controller.abort();
171
+ await running[1].promise;
172
+ active.delete(running[0]);
173
+ }
174
+ const saved = await options.store.recoverResult(identity);
175
+ recovery.controller.signal.throwIfAborted();
176
+ const last = saved?.events.at(-1);
177
+ if (!saved || last?.type !== "turn.end")
178
+ throw new Error("No confirmed result");
179
+ recovery.result = { state: saved.state, event: last };
180
+ for (const event of saved.events)
181
+ send({ type: "runtime.event", requestId: frame.requestId, event });
182
+ }
183
+ catch (error) {
184
+ if (!recovery.controller.signal.aborted) {
185
+ console.error("Runtime recovery failed; original files retained:", error);
186
+ try {
187
+ send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", uncertain: true, message: "Result unavailable; files retained / 结果不可用,原始文件已保留" } });
188
+ }
189
+ catch { /* The next connection can read the same result. */ }
190
+ }
191
+ active.delete(frame.requestId);
192
+ }
193
+ })();
194
+ return;
195
+ }
196
+ if (frame.input.spaceId !== options.spaceId || !options.capabilities.harnesses.includes(frame.input.harness))
197
+ throw new Error("Invalid Runtime target");
198
+ const previous = active.get(frame.requestId);
199
+ if (previous) {
200
+ if (previous.sessionId !== frame.input.sessionId || previous.turnId !== frame.input.turnId || previous.harness !== frame.input.harness)
201
+ throw new Error("Runtime execution identity changed");
202
+ await previous.promise;
203
+ const saved = await options.store.recoverResult(frame.input, frame.requestId);
204
+ if (saved)
205
+ for (const event of saved.events)
206
+ send({ type: "runtime.event", requestId: frame.requestId, event });
207
+ return;
208
+ }
209
+ const requestContext = async (executionSignal, historyOnly = false) => {
210
+ const signal = AbortSignal.any([executionSignal, disconnected.signal]);
211
+ signal.throwIfAborted();
212
+ const pendingTurnIds = await options.store.pendingTurnIds(frame.input.sessionId);
213
+ return new Promise((resolve, reject) => {
214
+ const abort = () => { clearTimeout(timeout); contexts.delete(frame.requestId); signal.removeEventListener("abort", abort); reject(new Error("Runtime context request aborted")); };
215
+ const timeout = setTimeout(abort, 60_000);
216
+ signal.addEventListener("abort", abort, { once: true });
217
+ contexts.set(frame.requestId, (context) => { clearTimeout(timeout); signal.removeEventListener("abort", abort); resolve(context); });
218
+ try {
219
+ send({ type: "runtime.event", requestId: frame.requestId, event: { type: "context.required", pendingTurnIds, ...(historyOnly ? { historyOnly: true } : {}) } });
220
+ }
221
+ catch {
222
+ abort();
223
+ }
224
+ if (signal.aborted)
225
+ abort();
226
+ });
227
+ };
228
+ if ([...active.values()].some((entry) => entry.sessionId === frame.input.sessionId) && !frame.input.context.complete)
229
+ frame.input.context = await requestContext(options.signal);
230
+ const repeated = seen.has(frame.requestId);
231
+ for (const [requestId, entry] of active) {
232
+ if (entry.sessionId !== frame.input.sessionId)
233
+ continue;
234
+ const resolved = frame.input.context.resolvedTurnIds?.includes(entry.turnId);
235
+ const settled = entry.result && frame.input.context.settledTurnIds?.includes(entry.turnId);
236
+ if (!resolved && !settled)
237
+ continue;
238
+ entry.controller.abort();
239
+ await entry.promise;
240
+ active.delete(requestId);
241
+ }
242
+ if ([...active.values()].some((entry) => entry.sessionId === frame.input.sessionId) || active.size >= 8) {
243
+ send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", message: "Local Runtime is busy / 本地 Runtime 繁忙" } });
244
+ return;
245
+ }
246
+ seen.add(frame.requestId);
247
+ if (seen.size > 4096) {
248
+ const oldest = seen.values().next().value;
249
+ if (oldest)
250
+ seen.delete(oldest);
251
+ }
252
+ const controller = new AbortController();
253
+ const execution = { controller, sessionId: frame.input.sessionId, turnId: frame.input.turnId, harness: frame.input.harness, promise: Promise.resolve() };
254
+ active.set(frame.requestId, execution);
255
+ const durableEvents = [];
256
+ const emit = (value) => {
257
+ if (value.type === "message.commit")
258
+ durableEvents.push(value);
259
+ send({ type: "runtime.event", requestId: frame.requestId, event: value });
260
+ };
261
+ execution.promise = (async () => {
262
+ try {
263
+ const saved = await options.store.recoverResult(frame.input, frame.requestId);
264
+ if (saved) {
265
+ const last = saved.events.at(-1);
266
+ if (last?.type !== "turn.end")
267
+ throw new Error("Incomplete saved Runtime result");
268
+ execution.result = { state: saved.state, event: last };
269
+ for (const event of saved.events)
270
+ emit(event);
271
+ return;
272
+ }
273
+ if (frame.resumeOnly || repeated) {
274
+ emit({ type: "turn.error", message: "Execution outcome is unknown; native files retained, no replay", uncertain: true });
275
+ active.delete(frame.requestId);
276
+ return;
277
+ }
278
+ const run = () => (frame.input.harness === "pi" ? executePi : executeCodex)(frame.input, options.harnesses, options.cwd, options.store, emit, controller.signal);
279
+ // Preparation can request context, then fall back once from native archive to DB.
280
+ // These retries precede started(), so they never replay model or tool work.
281
+ for (let attempt = 0;; attempt++) {
282
+ try {
283
+ execution.result = await run();
284
+ break;
285
+ }
286
+ catch (error) {
287
+ if (!(error instanceof ContextRequiredError) || attempt >= 2)
288
+ throw error;
289
+ frame.input.context = await requestContext(controller.signal, error.historyOnly);
290
+ }
291
+ }
292
+ await options.store.recordResult(execution.result.state, frame.requestId, [...durableEvents, execution.result.event]);
293
+ emit(execution.result.event);
294
+ }
295
+ catch (error) {
296
+ try {
297
+ emit({ type: "turn.error", message: error instanceof Error ? error.message : String(error), uncertain: !!execution.result || frame.resumeOnly === true || error instanceof ProcessCleanupUncertainError });
298
+ }
299
+ catch { /* Native files remain for recovery. */ }
300
+ active.delete(frame.requestId);
301
+ }
302
+ })();
303
+ })().catch((error) => { console.error("Runtime protocol error:", error); stop(); });
304
+ });
305
+ readyTimer = setTimeout(() => socket.close(4408, "Runtime handshake timed out"), 15_000);
306
+ if (options.signal.aborted)
307
+ stop();
308
+ try {
309
+ await closed;
310
+ await Promise.allSettled([...active.values()].map((entry) => entry.promise));
311
+ }
312
+ finally {
313
+ clearTimeout(readyTimer);
314
+ clearInterval(heartbeat);
315
+ options.signal.removeEventListener("abort", stop);
316
+ }
317
+ return fatal ? "fatal" : conflict ? "conflict" : "retry";
318
+ }
@@ -0,0 +1,19 @@
1
+ import type { ContentBlock, RuntimeCapabilities, RuntimeExecutionEvent, RuntimeTurnInput } from "@neta-art/cohub";
2
+ import { type JsonRecord } from "./json-rpc.js";
3
+ import type { RuntimeSessionStore, NativeSession } from "./session-store.js";
4
+ export type HarnessOptions = {
5
+ pi?: string;
6
+ codex?: string;
7
+ };
8
+ export type HarnessResult = {
9
+ state: NativeSession;
10
+ event: Extract<RuntimeExecutionEvent, {
11
+ type: "turn.end";
12
+ }>;
13
+ };
14
+ export declare function piContent(value: unknown): ContentBlock[];
15
+ export declare function promptText(content: ContentBlock[]): string;
16
+ export declare function discoverHarnesses(harnesses: ("pi" | "codex")[], options: HarnessOptions, cwd: string): Promise<RuntimeCapabilities>;
17
+ export declare function executePi(input: RuntimeTurnInput, options: HarnessOptions, cwd: string, store: RuntimeSessionStore, emit: (event: RuntimeExecutionEvent) => void, signal: AbortSignal): Promise<HarnessResult>;
18
+ export declare function codexItemContent(item: JsonRecord): ContentBlock[];
19
+ export declare function executeCodex(input: RuntimeTurnInput, options: HarnessOptions, cwd: string, store: RuntimeSessionStore, emit: (event: RuntimeExecutionEvent) => void, signal: AbortSignal): Promise<HarnessResult>;