@neta-art/cohub-cli 10.4.4 → 10.6.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 +18 -2
- package/dist/commands/runtime.js +21 -8
- package/dist/commands/space-wallet.d.ts +2 -0
- package/dist/commands/space-wallet.js +64 -0
- package/dist/commands/spaces.d.ts +5 -0
- package/dist/commands/spaces.js +55 -8
- package/dist/runtime/archive-store.d.ts +2 -2
- package/dist/runtime/archive-store.js +2 -2
- package/dist/runtime/diagnostics.d.ts +3 -3
- package/dist/runtime/harnesses/chained-projection.d.ts +40 -0
- package/dist/runtime/harnesses/chained-projection.js +95 -0
- package/dist/runtime/harnesses/claude.d.ts +8 -0
- package/dist/runtime/harnesses/claude.js +94 -0
- package/dist/runtime/harnesses/codex.d.ts +4 -0
- package/dist/runtime/harnesses/codex.js +229 -0
- package/dist/runtime/harnesses/index.d.ts +16 -0
- package/dist/runtime/harnesses/index.js +57 -0
- package/dist/runtime/harnesses/pi.d.ts +4 -0
- package/dist/runtime/harnesses/pi.js +247 -0
- package/dist/runtime/harnesses/process.d.ts +31 -0
- package/dist/runtime/harnesses/process.js +75 -0
- package/dist/runtime/harnesses/stream-json.d.ts +28 -0
- package/dist/runtime/harnesses/stream-json.js +291 -0
- package/dist/runtime/harnesses/types.d.ts +108 -0
- package/dist/runtime/harnesses/types.js +1 -0
- package/dist/runtime/harnesses/workbuddy.d.ts +15 -0
- package/dist/runtime/harnesses/workbuddy.js +99 -0
- package/dist/runtime/json-rpc.d.ts +7 -1
- package/dist/runtime/json-rpc.js +23 -2
- package/dist/runtime/launch.d.ts +6 -4
- package/dist/runtime/launch.js +21 -11
- package/dist/runtime/native/adapters.d.ts +11 -28
- package/dist/runtime/native/adapters.js +7 -75
- package/dist/runtime/native/archive.d.ts +9 -7
- package/dist/runtime/native/archive.js +99 -38
- package/dist/runtime/native/attach.d.ts +3 -5
- package/dist/runtime/native/attach.js +8 -2
- package/dist/runtime/native/daemon.d.ts +2 -2
- package/dist/runtime/native/daemon.js +5 -4
- package/dist/runtime/native/execution.d.ts +9 -30
- package/dist/runtime/native/execution.js +7 -325
- package/dist/runtime/native/identity.d.ts +4 -4
- package/dist/runtime/native/identity.js +4 -4
- package/dist/runtime/native/ingest.d.ts +2 -2
- package/dist/runtime/native/ingest.js +20 -25
- package/dist/runtime/native/sessions.d.ts +4 -4
- package/dist/runtime/native/sessions.js +12 -22
- package/dist/runtime/native/transcript.d.ts +3 -0
- package/dist/runtime/native/transcript.js +6 -5
- package/dist/runtime/native/translate.d.ts +2 -0
- package/dist/runtime/native/translate.js +34 -1
- package/dist/runtime/native/version.js +1 -1
- package/dist/runtime/presentation.js +2 -1
- package/dist/runtime/projection-store.js +2 -12
- package/dist/runtime/supervisor.d.ts +4 -4
- package/dist/runtime/supervisor.js +1 -1
- package/dist/runtime/turn-projection.d.ts +1 -1
- package/dist/runtime/turn-projection.js +2 -2
- package/package.json +2 -2
- package/dist/runtime/harness.d.ts +0 -26
- package/dist/runtime/harness.js +0 -142
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { type HarnessOptions } from "../harness.js";
|
|
1
|
+
import { type RuntimeTurnInput } from "@neta-art/cohub";
|
|
3
2
|
import type { RuntimeArchiveStore } from "../archive-store.js";
|
|
4
3
|
import { type RuntimeDiagnosticContext, type RuntimeDiagnostics } from "../diagnostics.js";
|
|
5
|
-
import {
|
|
4
|
+
import type { HarnessExecutables, HarnessResult, HarnessTurn } from "../harnesses/types.js";
|
|
5
|
+
import type { CodexShared } from "./codex-link.js";
|
|
6
6
|
import type { NativeIngest } from "./ingest.js";
|
|
7
|
-
import {
|
|
7
|
+
import type { PiLink } from "./pi-link.js";
|
|
8
8
|
import type { ExecutionResults } from "./results.js";
|
|
9
|
-
import type {
|
|
9
|
+
import type { NativeSessions } from "./sessions.js";
|
|
10
|
+
export type { HarnessResult } from "../harnesses/types.js";
|
|
10
11
|
export type Executor = {
|
|
11
12
|
cwd: string;
|
|
12
|
-
executables:
|
|
13
|
+
executables: HarnessExecutables;
|
|
13
14
|
sessions: NativeSessions;
|
|
14
15
|
results: ExecutionResults;
|
|
15
16
|
ingest: NativeIngest;
|
|
@@ -19,27 +20,5 @@ export type Executor = {
|
|
|
19
20
|
piExtension: string;
|
|
20
21
|
diagnostics?: RuntimeDiagnostics;
|
|
21
22
|
};
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
event: Extract<RuntimeExecutionEvent, {
|
|
25
|
-
type: "turn.end";
|
|
26
|
-
}>;
|
|
27
|
-
uncertainCleanup?: {
|
|
28
|
-
processGroupId: number;
|
|
29
|
-
error: string;
|
|
30
|
-
};
|
|
31
|
-
};
|
|
32
|
-
type Turn = {
|
|
33
|
-
input: RuntimeTurnInput;
|
|
34
|
-
emit: (event: RuntimeExecutionEvent) => void;
|
|
35
|
-
signal: AbortSignal;
|
|
36
|
-
diverged: string | null;
|
|
37
|
-
started: (session: NativeSession) => Promise<void>;
|
|
38
|
-
};
|
|
39
|
-
/**
|
|
40
|
-
* Run one Cohub Turn in a local harness. Both harnesses are driven through the same interface a
|
|
41
|
-
* terminal user shares: Pi through the Cohub extension inside the Pi process, Codex through its
|
|
42
|
-
* app-server.
|
|
43
|
-
*/
|
|
44
|
-
export declare function executeTurn(executor: Executor, input: RuntimeTurnInput, emit: Turn["emit"], signal: AbortSignal, requestId: string | null, context?: RuntimeDiagnosticContext): Promise<HarnessResult>;
|
|
45
|
-
export {};
|
|
23
|
+
/** Run one Cohub Turn in a local harness through its adapter. */
|
|
24
|
+
export declare function executeTurn(executor: Executor, input: RuntimeTurnInput, emit: HarnessTurn["emit"], signal: AbortSignal, requestId: string | null, context?: RuntimeDiagnosticContext): Promise<HarnessResult>;
|
|
@@ -1,26 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { JsonRpcProcess, record, RpcProcessClosedError } from "../json-rpc.js";
|
|
3
|
-
import { describeError } from "../../network-error.js";
|
|
4
|
-
import { ProcessCleanupUncertainError } from "../process-group.js";
|
|
1
|
+
import { assertHarnessAccess } from "@neta-art/cohub";
|
|
5
2
|
import { serializeDiagnosticError } from "../diagnostics.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
const text = (value) => typeof value === "string" ? value : "";
|
|
10
|
-
/** A stop request that the harness does not honour within this long ends its private process. */
|
|
11
|
-
const ABORT_ESCALATION_MS = 5_000;
|
|
12
|
-
/** Pause before asking a busy Pi again, so a Turn that is starting has reported itself. */
|
|
13
|
-
const PI_BUSY_RETRY_MS = 100;
|
|
14
|
-
/**
|
|
15
|
-
* Run one Cohub Turn in a local harness. Both harnesses are driven through the same interface a
|
|
16
|
-
* terminal user shares: Pi through the Cohub extension inside the Pi process, Codex through its
|
|
17
|
-
* app-server.
|
|
18
|
-
*/
|
|
3
|
+
import { harnessAdapter } from "../harnesses/index.js";
|
|
4
|
+
import { closeHost } from "../harnesses/process.js";
|
|
5
|
+
/** Run one Cohub Turn in a local harness through its adapter. */
|
|
19
6
|
export async function executeTurn(executor, input, emit, signal, requestId, context) {
|
|
20
7
|
signal.throwIfAborted();
|
|
8
|
+
assertHarnessAccess(input.harness, input.accessMode);
|
|
21
9
|
const diverged = await executor.results.admit(input);
|
|
22
|
-
const turn = { input, emit, signal, diverged, started: (session) => executor.results.start(session, input, requestId) };
|
|
23
|
-
const run = await (input.harness
|
|
10
|
+
const turn = { input, emit, signal, diverged, started: (session) => executor.results.start(session, input, requestId), context };
|
|
11
|
+
const run = await harnessAdapter(input.harness).run(executor, turn);
|
|
24
12
|
let uncertainCleanup;
|
|
25
13
|
if (run.host)
|
|
26
14
|
uncertainCleanup = await closeHost(run.host, run.message.errorMessage);
|
|
@@ -33,309 +21,3 @@ export async function executeTurn(executor, input, emit, signal, requestId, cont
|
|
|
33
21
|
executor.ingest.advance(run.session.path, input.turnId);
|
|
34
22
|
return { session: run.session, event: { type: "turn.end", message, resume: run.resume, archive }, ...(uncertainCleanup ? { uncertainCleanup } : {}) };
|
|
35
23
|
}
|
|
36
|
-
/** Variables a Turn's tools see, so a `cohub` call inside a tool knows where it runs. */
|
|
37
|
-
const turnEnvironment = (input) => ({ COHUB_SPACE_ID: input.spaceId, COHUB_SESSION_ID: input.sessionId, COHUB_TURN_ID: input.turnId });
|
|
38
|
-
async function runPi(executor, turn, context) {
|
|
39
|
-
const { input, signal } = turn;
|
|
40
|
-
if (input.accessMode === "read_only")
|
|
41
|
-
throw new Error("Pi cannot enforce read-only access; select Cohub or Codex");
|
|
42
|
-
// Cohub drives every Pi through its extension, which needs the control socket and a recent Pi.
|
|
43
|
-
if (executor.pi.unavailable)
|
|
44
|
-
throw new Error(executor.pi.unavailable);
|
|
45
|
-
const prepared = await executor.sessions.prepare({
|
|
46
|
-
sessionId: input.sessionId, turnId: input.turnId, harness: "pi", cwd: executor.cwd,
|
|
47
|
-
provider: input.provider, context: input.context, signal,
|
|
48
|
-
// A terminal Pi is continued only through its extension; without one it is read-only.
|
|
49
|
-
resumable: (transcript) => transcript.path !== turn.diverged && (transcript.origin === "cohub" || executor.pi.find(transcript.nativeSessionId) !== null),
|
|
50
|
-
});
|
|
51
|
-
let session = prepared.session;
|
|
52
|
-
let link = executor.pi.find(session.nativeSessionId);
|
|
53
|
-
let host = null;
|
|
54
|
-
let message = { ordinal: 0, content: [] };
|
|
55
|
-
let begun = false;
|
|
56
|
-
try {
|
|
57
|
-
if (!link) {
|
|
58
|
-
host = new JsonRpcProcess(...harnessCommand(executor.executables.pi || "pi", ["--mode", "rpc", "--session", session.path, "-e", executor.piExtension], executor.cwd), executor.cwd, "pi", turnEnvironment(input));
|
|
59
|
-
watchHost(host, executor.diagnostics, context);
|
|
60
|
-
const rpc = host;
|
|
61
|
-
// Nobody is at this Pi's terminal to answer an extension's question.
|
|
62
|
-
rpc.onEvent((event) => {
|
|
63
|
-
if (event.type === "extension_ui_request" && ["confirm", "select", "input", "editor"].includes(text(event.method)))
|
|
64
|
-
rpc.write({ type: "extension_ui_response", id: event.id, cancelled: true });
|
|
65
|
-
});
|
|
66
|
-
await configurePiHost(host, input);
|
|
67
|
-
link = await Promise.race([
|
|
68
|
-
executor.pi.waitFor({ sessionId: session.nativeSessionId, sessionFile: session.path }, signal),
|
|
69
|
-
new Promise((_, reject) => host?.onFailure(reject)),
|
|
70
|
-
]);
|
|
71
|
-
}
|
|
72
|
-
const piSession = link;
|
|
73
|
-
session = { ...session, nativeSessionId: piSession.sessionId };
|
|
74
|
-
// Never interleave with a Turn a terminal user is running; follow it instead.
|
|
75
|
-
await executor.pi.idle(piSession.sessionId, signal);
|
|
76
|
-
const content = await piPrompt(input);
|
|
77
|
-
signal.throwIfAborted();
|
|
78
|
-
await turn.started(session);
|
|
79
|
-
begun = true;
|
|
80
|
-
const translator = piTranslator(turn.emit);
|
|
81
|
-
const model = !host && input.model && input.provider ? { provider: input.provider, id: input.model } : null;
|
|
82
|
-
await new Promise((resolve, reject) => {
|
|
83
|
-
let escalation;
|
|
84
|
-
/** Events held until Pi takes the prompt; until then they belong to a terminal Turn. */
|
|
85
|
-
let held = [];
|
|
86
|
-
let done = false;
|
|
87
|
-
const finish = (error) => {
|
|
88
|
-
if (done)
|
|
89
|
-
return;
|
|
90
|
-
done = true;
|
|
91
|
-
off();
|
|
92
|
-
signal.removeEventListener("abort", abort);
|
|
93
|
-
clearTimeout(escalation);
|
|
94
|
-
if (error)
|
|
95
|
-
reject(error);
|
|
96
|
-
else
|
|
97
|
-
resolve();
|
|
98
|
-
};
|
|
99
|
-
const feed = (event) => {
|
|
100
|
-
try {
|
|
101
|
-
if (translator.push(event))
|
|
102
|
-
finish();
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
finish(error);
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
const off = executor.pi.subscribe(piSession.sessionId, (event) => {
|
|
109
|
-
if (event.type === PI_DISCONNECTED) {
|
|
110
|
-
finish(new Error("Pi exited during the Turn"));
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
if (held)
|
|
114
|
-
held.push(event);
|
|
115
|
-
else
|
|
116
|
-
feed(event);
|
|
117
|
-
});
|
|
118
|
-
const abort = () => {
|
|
119
|
-
// Not taken yet: the Pi is running someone else's Turn, which is not Cohub's to stop.
|
|
120
|
-
if (held) {
|
|
121
|
-
finish(signal.reason ?? new Error("Aborted"));
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
void executor.pi.abort(piSession.sessionId).catch(() => undefined);
|
|
125
|
-
if (host)
|
|
126
|
-
escalation = setTimeout(() => void host?.close().catch(() => undefined), ABORT_ESCALATION_MS);
|
|
127
|
-
};
|
|
128
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
129
|
-
void (async () => {
|
|
130
|
-
for (;;) {
|
|
131
|
-
// Only what follows this request can be this Turn's.
|
|
132
|
-
held = [];
|
|
133
|
-
const { accepted } = await executor.pi.prompt(piSession.sessionId, { turnId: input.turnId, content, model, thinkingLevel: host ? null : input.thinkingLevel ?? null, env: turnEnvironment(input) });
|
|
134
|
-
if (accepted)
|
|
135
|
-
break;
|
|
136
|
-
// The terminal started a Turn first: wait for it to settle, then ask again.
|
|
137
|
-
await new Promise((resolve) => setTimeout(resolve, PI_BUSY_RETRY_MS));
|
|
138
|
-
await executor.pi.idle(piSession.sessionId, signal);
|
|
139
|
-
if (done)
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
const events = held ?? [];
|
|
143
|
-
held = null;
|
|
144
|
-
if (done) {
|
|
145
|
-
void executor.pi.abort(piSession.sessionId).catch(() => undefined);
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
for (const event of events)
|
|
149
|
-
feed(event);
|
|
150
|
-
if (signal.aborted)
|
|
151
|
-
abort();
|
|
152
|
-
})().catch(finish);
|
|
153
|
-
if (signal.aborted)
|
|
154
|
-
abort();
|
|
155
|
-
});
|
|
156
|
-
message = translator.last();
|
|
157
|
-
// The Pi keeps running for its terminal user; its tools no longer work for this Turn.
|
|
158
|
-
if (!host)
|
|
159
|
-
void executor.pi.context(piSession.sessionId, { COHUB_TURN_ID: null }).catch(() => undefined);
|
|
160
|
-
}
|
|
161
|
-
catch (error) {
|
|
162
|
-
// Before the prompt, nothing ran and the error is the outcome.
|
|
163
|
-
if (!begun) {
|
|
164
|
-
if (host)
|
|
165
|
-
await closeHost(host, null).catch(() => undefined);
|
|
166
|
-
throw error;
|
|
167
|
-
}
|
|
168
|
-
message = { ...message, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : describeError(error) };
|
|
169
|
-
}
|
|
170
|
-
return { session, resume: prepared.resume, message, host };
|
|
171
|
-
}
|
|
172
|
-
/** A Pi that Cohub starts is configured through its RPC before the extension takes the prompt. */
|
|
173
|
-
async function configurePiHost(host, input) {
|
|
174
|
-
if (input.model) {
|
|
175
|
-
let provider = input.provider;
|
|
176
|
-
if (!provider) {
|
|
177
|
-
const catalog = await host.request("get_available_models");
|
|
178
|
-
const matches = (Array.isArray(catalog.models) ? catalog.models : []).map(record).filter((model) => model.id === input.model);
|
|
179
|
-
if (matches.length !== 1)
|
|
180
|
-
throw new Error("Select a provider for this local model");
|
|
181
|
-
provider = text(matches[0]?.provider);
|
|
182
|
-
}
|
|
183
|
-
await host.request("set_model", { provider, modelId: input.model });
|
|
184
|
-
}
|
|
185
|
-
if (input.thinkingLevel)
|
|
186
|
-
await host.request("set_thinking_level", { level: input.thinkingLevel });
|
|
187
|
-
}
|
|
188
|
-
async function piPrompt(input) {
|
|
189
|
-
const blocks = input.messages.flatMap((message) => message.content);
|
|
190
|
-
return await Promise.all(blocks.map(async (block) => {
|
|
191
|
-
if (block.type === "image")
|
|
192
|
-
return await imageForPi(block);
|
|
193
|
-
return { type: "text", text: block.type === "text" ? block.text : JSON.stringify(block) };
|
|
194
|
-
}));
|
|
195
|
-
}
|
|
196
|
-
async function runCodex(executor, turn, context) {
|
|
197
|
-
const { input, signal } = turn;
|
|
198
|
-
const shared = await executor.codex.get();
|
|
199
|
-
const prepared = await executor.sessions.prepare({
|
|
200
|
-
sessionId: input.sessionId, turnId: input.turnId, harness: "codex", cwd: executor.cwd,
|
|
201
|
-
provider: input.provider, context: input.context, signal,
|
|
202
|
-
// Only the shared server coordinates with terminal clients; a private one never opens their files.
|
|
203
|
-
resumable: (transcript) => transcript.path !== turn.diverged && (transcript.origin === "cohub" || shared !== null),
|
|
204
|
-
});
|
|
205
|
-
let session = prepared.session;
|
|
206
|
-
let host = null;
|
|
207
|
-
let channel = shared;
|
|
208
|
-
let message = { ordinal: 0, content: [] };
|
|
209
|
-
let begun = false;
|
|
210
|
-
try {
|
|
211
|
-
if (!channel) {
|
|
212
|
-
host = new JsonRpcProcess(...harnessCommand(executor.executables.codex || "codex", ["app-server", "--listen", "stdio://"], executor.cwd), executor.cwd, "codex", turnEnvironment(input));
|
|
213
|
-
watchHost(host, executor.diagnostics, context);
|
|
214
|
-
channel = await openPrivateServer(host);
|
|
215
|
-
}
|
|
216
|
-
const codex = channel;
|
|
217
|
-
const threadOptions = {
|
|
218
|
-
cwd: executor.cwd,
|
|
219
|
-
...(input.model ? { model: input.model } : {}),
|
|
220
|
-
...(input.provider && input.provider !== "codex" ? { modelProvider: input.provider } : {}),
|
|
221
|
-
...(input.accessMode === "read_only" ? { sandbox: "read-only" } : {}),
|
|
222
|
-
// Tools of a shared server otherwise see the environment of whoever started that server.
|
|
223
|
-
config: { "shell_environment_policy.set.COHUB_SPACE_ID": input.spaceId, "shell_environment_policy.set.COHUB_SESSION_ID": input.sessionId },
|
|
224
|
-
};
|
|
225
|
-
const existing = { ...threadOptions, threadId: session.nativeSessionId, path: session.path, excludeTurns: true };
|
|
226
|
-
const opened = prepared.resume === "new" ? await codex.request("thread/start", threadOptions)
|
|
227
|
-
: prepared.resume === "restored" ? await codex.request("thread/fork", existing)
|
|
228
|
-
: await codex.request("thread/resume", existing);
|
|
229
|
-
const thread = record(opened.thread);
|
|
230
|
-
if (typeof thread.id !== "string" || typeof thread.path !== "string")
|
|
231
|
-
throw new Error("Codex did not provide a durable native thread");
|
|
232
|
-
const threadId = thread.id;
|
|
233
|
-
if (thread.path !== session.path || threadId !== session.nativeSessionId) {
|
|
234
|
-
session = { ...session, nativeSessionId: threadId, path: thread.path };
|
|
235
|
-
executor.ingest.adopt(session, input.sessionId);
|
|
236
|
-
}
|
|
237
|
-
const translator = codexTranslator(turn.emit, { provider: text(opened.modelProvider) || "codex", model: text(opened.model) || input.model || null });
|
|
238
|
-
const content = await codexPrompt(input);
|
|
239
|
-
signal.throwIfAborted();
|
|
240
|
-
await turn.started(session);
|
|
241
|
-
begun = true;
|
|
242
|
-
let nativeTurnId = null;
|
|
243
|
-
executor.codex.driving.add(threadId);
|
|
244
|
-
await new Promise((resolve, reject) => {
|
|
245
|
-
let escalation;
|
|
246
|
-
let done = false;
|
|
247
|
-
const finish = (error) => {
|
|
248
|
-
if (done)
|
|
249
|
-
return;
|
|
250
|
-
done = true;
|
|
251
|
-
off();
|
|
252
|
-
offClose();
|
|
253
|
-
signal.removeEventListener("abort", abort);
|
|
254
|
-
clearTimeout(escalation);
|
|
255
|
-
if (error)
|
|
256
|
-
reject(error);
|
|
257
|
-
else
|
|
258
|
-
resolve();
|
|
259
|
-
};
|
|
260
|
-
const interrupt = () => { if (nativeTurnId)
|
|
261
|
-
void codex.request("turn/interrupt", { threadId, turnId: nativeTurnId }).catch(() => undefined); };
|
|
262
|
-
const off = codex.onEvent((event) => {
|
|
263
|
-
const params = event.params;
|
|
264
|
-
if (event.id != null) {
|
|
265
|
-
// An unattended Cohub Turn never grants an escalation.
|
|
266
|
-
if (!codex.shared || params.threadId === threadId && (!nativeTurnId || params.turnId === nativeTurnId))
|
|
267
|
-
codex.refuse(event.id, "Approval requires an interactive local client");
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
if (params.threadId !== threadId)
|
|
271
|
-
return;
|
|
272
|
-
// A resumed thread replays its last usage; only this Turn's counts belong to it.
|
|
273
|
-
if (event.method === "thread/tokenUsage/updated" && params.turnId !== nativeTurnId)
|
|
274
|
-
return;
|
|
275
|
-
if (event.method === "turn/started" && !nativeTurnId) {
|
|
276
|
-
nativeTurnId = text(record(params.turn).id) || null;
|
|
277
|
-
if (signal.aborted)
|
|
278
|
-
interrupt();
|
|
279
|
-
}
|
|
280
|
-
if (event.method === "turn/completed" && nativeTurnId && text(record(params.turn).id) !== nativeTurnId)
|
|
281
|
-
return;
|
|
282
|
-
try {
|
|
283
|
-
if (translator.push(event))
|
|
284
|
-
finish();
|
|
285
|
-
}
|
|
286
|
-
catch (error) {
|
|
287
|
-
finish(error);
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
const offClose = codex.onClose((error) => finish(error));
|
|
291
|
-
const abort = () => {
|
|
292
|
-
interrupt();
|
|
293
|
-
if (host)
|
|
294
|
-
escalation = setTimeout(() => void host?.close().catch(() => undefined), ABORT_ESCALATION_MS);
|
|
295
|
-
};
|
|
296
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
297
|
-
codex.request("turn/start", { threadId, clientUserMessageId: input.turnId, input: content, ...(input.thinkingLevel ? { effort: input.thinkingLevel === "off" ? "none" : input.thinkingLevel } : {}) })
|
|
298
|
-
.then((result) => { nativeTurnId ??= text(record(result.turn).id) || null; if (signal.aborted)
|
|
299
|
-
interrupt(); })
|
|
300
|
-
.catch(finish);
|
|
301
|
-
}).finally(() => executor.codex.driving.delete(threadId));
|
|
302
|
-
message = translator.last();
|
|
303
|
-
}
|
|
304
|
-
catch (error) {
|
|
305
|
-
if (!begun) {
|
|
306
|
-
if (host)
|
|
307
|
-
await closeHost(host, null).catch(() => undefined);
|
|
308
|
-
throw error;
|
|
309
|
-
}
|
|
310
|
-
message = { ...message, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : describeError(error) };
|
|
311
|
-
}
|
|
312
|
-
return { session, resume: prepared.resume, message, host };
|
|
313
|
-
}
|
|
314
|
-
async function codexPrompt(input) {
|
|
315
|
-
return await Promise.all(input.messages.flatMap((message) => message.content).map(async (block) => {
|
|
316
|
-
if (block.type === "image") {
|
|
317
|
-
const image = await imageForPi(block);
|
|
318
|
-
return { type: "image", url: `data:${image.mimeType};base64,${image.data}` };
|
|
319
|
-
}
|
|
320
|
-
return { type: "text", text: block.type === "text" ? block.text : JSON.stringify(block), text_elements: [] };
|
|
321
|
-
}));
|
|
322
|
-
}
|
|
323
|
-
function watchHost(host, diagnostics, context) {
|
|
324
|
-
if (!diagnostics)
|
|
325
|
-
return;
|
|
326
|
-
host.onFailure((error) => {
|
|
327
|
-
if (!(error instanceof RpcProcessClosedError))
|
|
328
|
-
diagnostics.log("error", "harness.rpc_process_failed", { error: serializeDiagnosticError(error) }, { ...context, component: "harness" });
|
|
329
|
-
});
|
|
330
|
-
host.onTimeout((method, timeoutMs) => diagnostics.log("error", "harness.rpc_timeout", { method, timeoutMs }, { ...context, component: "harness" }));
|
|
331
|
-
}
|
|
332
|
-
/** Close a harness process Cohub started. An unconfirmable cleanup marks the result uncertain; it never masks it. */
|
|
333
|
-
async function closeHost(host, executionError) {
|
|
334
|
-
return await host.close().then(() => undefined, (cleanupError) => {
|
|
335
|
-
const processGroupId = host.processGroupId;
|
|
336
|
-
if (!(cleanupError instanceof ProcessCleanupUncertainError) || processGroupId == null)
|
|
337
|
-
throw cleanupError;
|
|
338
|
-
const detail = executionError ? `; execution error: ${executionError}` : "";
|
|
339
|
-
return { processGroupId, error: `${cleanupError.message}${detail}` };
|
|
340
|
-
});
|
|
341
|
-
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import type { LocalHarness } from "@neta-art/cohub";
|
|
1
2
|
/** Deterministic UUID-shaped id. A native Turn always names the same cloud Turn. */
|
|
2
3
|
export declare function nativeStableId(value: string): string;
|
|
3
4
|
/**
|
|
4
5
|
* Cloud Turn id of a native Turn. A transcript can reach more than one Space (a directory rebound
|
|
5
|
-
* to another Space, or another account), so the Space is part of the identity; within it,
|
|
6
|
-
* Turn
|
|
7
|
-
* session.
|
|
6
|
+
* to another Space, or another account), so the Space is part of the identity; within it, globally
|
|
7
|
+
* unique Turn keys (Codex: a fork shares its history's Turns) stand alone and others are per session.
|
|
8
8
|
*/
|
|
9
|
-
export declare function nativeTurnId(spaceId: string, harness:
|
|
9
|
+
export declare function nativeTurnId(spaceId: string, harness: LocalHarness, nativeSessionId: string, turnKey: string): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { transcriptFormat } from "../harnesses/index.js";
|
|
2
3
|
/** Deterministic UUID-shaped id. A native Turn always names the same cloud Turn. */
|
|
3
4
|
export function nativeStableId(value) {
|
|
4
5
|
const hex = createHash("sha256").update(value).digest("hex");
|
|
@@ -6,10 +7,9 @@ export function nativeStableId(value) {
|
|
|
6
7
|
}
|
|
7
8
|
/**
|
|
8
9
|
* Cloud Turn id of a native Turn. A transcript can reach more than one Space (a directory rebound
|
|
9
|
-
* to another Space, or another account), so the Space is part of the identity; within it,
|
|
10
|
-
* Turn
|
|
11
|
-
* session.
|
|
10
|
+
* to another Space, or another account), so the Space is part of the identity; within it, globally
|
|
11
|
+
* unique Turn keys (Codex: a fork shares its history's Turns) stand alone and others are per session.
|
|
12
12
|
*/
|
|
13
13
|
export function nativeTurnId(spaceId, harness, nativeSessionId, turnKey) {
|
|
14
|
-
return nativeStableId(JSON.stringify(harness
|
|
14
|
+
return nativeStableId(JSON.stringify(transcriptFormat(harness).globalTurnKeys ? ["native", spaceId, harness, turnKey] : ["native", spaceId, harness, nativeSessionId, turnKey]));
|
|
15
15
|
}
|
|
@@ -72,11 +72,11 @@ export type IngestTransport = {
|
|
|
72
72
|
};
|
|
73
73
|
export type IngestHooks = {
|
|
74
74
|
/**
|
|
75
|
-
* Control-plane certainty about a
|
|
75
|
+
* Control-plane certainty about a session's file as last written at `mtimeMs`: `true` when the
|
|
76
76
|
* agent settled after that write, `false` while it runs, `null` when the control plane cannot
|
|
77
77
|
* tell.
|
|
78
78
|
*/
|
|
79
|
-
|
|
79
|
+
settled?: (state: TranscriptState, mtimeMs: number) => boolean | null;
|
|
80
80
|
controllable?: (state: TranscriptState) => boolean;
|
|
81
81
|
abortTurn?: (state: TranscriptState) => void;
|
|
82
82
|
onGrowth?: (state: TranscriptState) => void;
|
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { NATIVE_INGEST_MAX_TURNS, SETTLED_TURN_STATUSES } from "@neta-art/cohub";
|
|
4
4
|
import { acceptsDirectory, acceptsTranscript, readSessionHeader, readTranscript, transcriptRoots } from "./adapters.js";
|
|
5
5
|
import { nativeTurnId } from "./identity.js";
|
|
6
|
-
import {
|
|
6
|
+
import { transcriptFormat } from "../harnesses/index.js";
|
|
7
7
|
import { containsNativePath } from "./paths.js";
|
|
8
8
|
const missing = (error) => error?.code === "ENOENT";
|
|
9
9
|
/** Transcripts read in parallel per lane. The live lane never waits behind a backfill. */
|
|
@@ -12,11 +12,9 @@ export const DEFAULT_IMPORT_CONCURRENCY = 4;
|
|
|
12
12
|
export const MAX_IMPORT_CONCURRENCY = 8;
|
|
13
13
|
/** A harness writes one Turn as many appends; filesystem events are coalesced for this long. */
|
|
14
14
|
const WATCH_DEBOUNCE_MS = 150;
|
|
15
|
-
/** A quiet
|
|
16
|
-
const
|
|
15
|
+
/** A quiet transcript whose last reply ended is settled after this long, unless the file records Turn ends. */
|
|
16
|
+
const QUIET_SETTLE_MS = 10_000;
|
|
17
17
|
const SETTLED_STATUSES = new Set(SETTLED_TURN_STATUSES);
|
|
18
|
-
/** Pi stop reasons that end a reply; anything else (a tool call) means more is coming. */
|
|
19
|
-
const PI_ENDED = ["stop", "length", "error", "aborted"];
|
|
20
18
|
/** A running Turn that shows signs of life tells the server at most this often. */
|
|
21
19
|
const ALIVE_INTERVAL_MS = 5 * 60_000;
|
|
22
20
|
/** A failed read is retried this many times with backoff; afterwards the next write or reconnect retries. */
|
|
@@ -28,15 +26,6 @@ const RETRY_MAX_MS = 5 * 60_000;
|
|
|
28
26
|
* small enough to answer well within the request timeout; the protocol allows more.
|
|
29
27
|
*/
|
|
30
28
|
const INGEST_BATCH_TURNS = Math.min(50, NATIVE_INGEST_MAX_TURNS);
|
|
31
|
-
/**
|
|
32
|
-
* Records that end or begin a Turn. New bytes of a known transcript are only searched for these;
|
|
33
|
-
* the transcript is parsed and sent when one appears.
|
|
34
|
-
*/
|
|
35
|
-
const BOUNDARIES = {
|
|
36
|
-
codex: ['"UserMessage"', '"user_message"', '"turn_complete"', '"task_complete"', '"turn_aborted"'],
|
|
37
|
-
pi: ['"role":"user"', ...PI_ENDED.map((reason) => `"stopReason":"${reason}"`)],
|
|
38
|
-
};
|
|
39
|
-
const BOUNDARY_OVERLAP = Math.max(...Object.values(BOUNDARIES).flat().map((token) => token.length));
|
|
40
29
|
const IMPORT_FAILURES_KEPT = 100;
|
|
41
30
|
/** Headers are read this many at a time; each costs one small read. */
|
|
42
31
|
const HEADER_CONCURRENCY = 16;
|
|
@@ -272,7 +261,8 @@ export class NativeIngest {
|
|
|
272
261
|
}
|
|
273
262
|
status() {
|
|
274
263
|
return {
|
|
275
|
-
transcripts
|
|
264
|
+
// Cohub Turns of other harnesses are tracked here too; only synced transcripts count.
|
|
265
|
+
transcripts: [...this.states.values()].filter((state) => this.harnesses.includes(state.harness)).length,
|
|
276
266
|
live: this.live.size + this.runningLive,
|
|
277
267
|
running: [...this.states.values()].filter((state) => state.runningTurnId).length,
|
|
278
268
|
import: { ...this.job, queued: this.importQueue.length, running: this.runningImport },
|
|
@@ -531,18 +521,21 @@ export class NativeIngest {
|
|
|
531
521
|
this.markDirty(path);
|
|
532
522
|
}
|
|
533
523
|
async crossesBoundary(path, harness, from, to) {
|
|
524
|
+
// New bytes of a known transcript are only searched for records that begin or end a Turn.
|
|
525
|
+
const tokens = transcriptFormat(harness).boundaries;
|
|
526
|
+
const overlap = Math.max(0, ...tokens.map((token) => token.length));
|
|
534
527
|
const file = await open(path, "r");
|
|
535
528
|
try {
|
|
536
|
-
const chunk = Buffer.alloc(Math.min(to - from +
|
|
529
|
+
const chunk = Buffer.alloc(Math.min(to - from + overlap, 1024 * 1024));
|
|
537
530
|
for (let position = from; position < to;) {
|
|
538
|
-
const start = Math.max(0, position -
|
|
531
|
+
const start = Math.max(0, position - overlap);
|
|
539
532
|
const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, to - start), start);
|
|
540
533
|
if (bytesRead <= position - start)
|
|
541
534
|
break;
|
|
542
535
|
const bytes = chunk.subarray(0, bytesRead);
|
|
543
536
|
// A token that ends inside the bytes already searched was seen before; it does not count again.
|
|
544
537
|
const fresh = position - start;
|
|
545
|
-
for (const token of
|
|
538
|
+
for (const token of tokens) {
|
|
546
539
|
for (let index = bytes.indexOf(token); index >= 0; index = bytes.indexOf(token, index + 1))
|
|
547
540
|
if (index + token.length > fresh)
|
|
548
541
|
return true;
|
|
@@ -566,17 +559,18 @@ export class NativeIngest {
|
|
|
566
559
|
return null;
|
|
567
560
|
}
|
|
568
561
|
tailSettled(state, tail, mtimeMs) {
|
|
569
|
-
|
|
562
|
+
const format = transcriptFormat(state.harness);
|
|
563
|
+
if (format.turnEndRecorded)
|
|
570
564
|
return tail.result !== null;
|
|
571
|
-
const certain = this.options.hooks?.
|
|
565
|
+
const certain = this.options.hooks?.settled?.(state, mtimeMs);
|
|
572
566
|
if (certain != null)
|
|
573
567
|
return certain;
|
|
574
|
-
if (!
|
|
568
|
+
if (!format.replyEnded?.(tail))
|
|
575
569
|
return false;
|
|
576
570
|
const idleMs = Date.now() - mtimeMs;
|
|
577
|
-
if (idleMs >=
|
|
571
|
+
if (idleMs >= QUIET_SETTLE_MS)
|
|
578
572
|
return true;
|
|
579
|
-
this.recheck(state.path,
|
|
573
|
+
this.recheck(state.path, QUIET_SETTLE_MS - idleMs);
|
|
580
574
|
return false;
|
|
581
575
|
}
|
|
582
576
|
recheck(path, delayMs) {
|
|
@@ -624,7 +618,7 @@ export class NativeIngest {
|
|
|
624
618
|
if (!settled && lane === "import")
|
|
625
619
|
break;
|
|
626
620
|
// A prompt still being recorded would change the Turn's identity; it is sent once complete.
|
|
627
|
-
if (!settled && state.harness
|
|
621
|
+
if (!settled && transcriptFormat(state.harness).userFinalMarked && !turn.userFinal)
|
|
628
622
|
break;
|
|
629
623
|
const turnId = derived(turn);
|
|
630
624
|
candidates.push({ turn, turnId, parentTurnId, settled });
|
|
@@ -736,11 +730,12 @@ export class NativeIngest {
|
|
|
736
730
|
return;
|
|
737
731
|
try {
|
|
738
732
|
const source = turn.path ?? state.path;
|
|
733
|
+
const archiveSource = transcriptFormat(state.harness).archiveSource;
|
|
739
734
|
await this.options.archives.stage({
|
|
740
735
|
sessionId: state.sessionId,
|
|
741
736
|
harness: state.harness,
|
|
742
737
|
nativeSessionId: state.nativeSessionId,
|
|
743
|
-
path:
|
|
738
|
+
path: archiveSource ? await archiveSource(source, join(this.options.root, "native", "rollouts")) : source,
|
|
744
739
|
sizeBytes: turn.endBytes,
|
|
745
740
|
expectedChecksum: turn.sha256,
|
|
746
741
|
}, turnId);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { type RuntimeContext } from "@neta-art/cohub";
|
|
1
|
+
import { type LocalHarness, type RuntimeContext } from "@neta-art/cohub";
|
|
2
2
|
import type { SessionTurnProjectionClient } from "../turn-projection.js";
|
|
3
3
|
import type { RuntimeArchiveStore } from "../archive-store.js";
|
|
4
4
|
import type { NativeIngest, TranscriptState } from "./ingest.js";
|
|
5
5
|
/** How the native file for a Turn was obtained; reported with the Turn result. */
|
|
6
6
|
export type ResumeKind = "native" | "restored" | "handoff" | "new";
|
|
7
7
|
export type NativeSession = {
|
|
8
|
-
harness:
|
|
8
|
+
harness: LocalHarness;
|
|
9
9
|
nativeSessionId: string;
|
|
10
10
|
path: string;
|
|
11
11
|
cwd: string;
|
|
@@ -26,14 +26,14 @@ export declare class NativeSessions {
|
|
|
26
26
|
ingest: NativeIngest;
|
|
27
27
|
onRestoreFailed?: (error: unknown, context: {
|
|
28
28
|
sessionId: string;
|
|
29
|
-
harness:
|
|
29
|
+
harness: LocalHarness;
|
|
30
30
|
unrestorable: boolean;
|
|
31
31
|
}) => void;
|
|
32
32
|
});
|
|
33
33
|
prepare(input: {
|
|
34
34
|
sessionId: string;
|
|
35
35
|
turnId: string;
|
|
36
|
-
harness:
|
|
36
|
+
harness: LocalHarness;
|
|
37
37
|
cwd: string;
|
|
38
38
|
provider?: string | null;
|
|
39
39
|
context: RuntimeContext;
|