@neta-art/cohub-cli 10.5.0 → 10.7.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.
Files changed (60) hide show
  1. package/README.md +8 -3
  2. package/dist/board-kernel.js +13 -10
  3. package/dist/commands/generations.js +23 -2
  4. package/dist/commands/runtime.js +21 -8
  5. package/dist/commands/spaces.js +3 -3
  6. package/dist/runtime/archive-store.d.ts +2 -2
  7. package/dist/runtime/archive-store.js +2 -2
  8. package/dist/runtime/diagnostics.d.ts +3 -3
  9. package/dist/runtime/harnesses/chained-projection.d.ts +40 -0
  10. package/dist/runtime/harnesses/chained-projection.js +95 -0
  11. package/dist/runtime/harnesses/claude.d.ts +8 -0
  12. package/dist/runtime/harnesses/claude.js +153 -0
  13. package/dist/runtime/harnesses/codex.d.ts +4 -0
  14. package/dist/runtime/harnesses/codex.js +229 -0
  15. package/dist/runtime/harnesses/index.d.ts +16 -0
  16. package/dist/runtime/harnesses/index.js +57 -0
  17. package/dist/runtime/harnesses/pi.d.ts +4 -0
  18. package/dist/runtime/harnesses/pi.js +247 -0
  19. package/dist/runtime/harnesses/process.d.ts +31 -0
  20. package/dist/runtime/harnesses/process.js +75 -0
  21. package/dist/runtime/harnesses/stream-json.d.ts +30 -0
  22. package/dist/runtime/harnesses/stream-json.js +292 -0
  23. package/dist/runtime/harnesses/types.d.ts +108 -0
  24. package/dist/runtime/harnesses/types.js +1 -0
  25. package/dist/runtime/harnesses/workbuddy.d.ts +15 -0
  26. package/dist/runtime/harnesses/workbuddy.js +99 -0
  27. package/dist/runtime/json-rpc.d.ts +7 -1
  28. package/dist/runtime/json-rpc.js +23 -2
  29. package/dist/runtime/launch.d.ts +6 -4
  30. package/dist/runtime/launch.js +21 -11
  31. package/dist/runtime/native/adapters.d.ts +11 -28
  32. package/dist/runtime/native/adapters.js +7 -75
  33. package/dist/runtime/native/archive.d.ts +9 -7
  34. package/dist/runtime/native/archive.js +99 -38
  35. package/dist/runtime/native/attach.d.ts +3 -5
  36. package/dist/runtime/native/attach.js +8 -2
  37. package/dist/runtime/native/daemon.d.ts +2 -2
  38. package/dist/runtime/native/daemon.js +5 -4
  39. package/dist/runtime/native/execution.d.ts +9 -30
  40. package/dist/runtime/native/execution.js +7 -325
  41. package/dist/runtime/native/identity.d.ts +4 -4
  42. package/dist/runtime/native/identity.js +4 -4
  43. package/dist/runtime/native/ingest.d.ts +2 -2
  44. package/dist/runtime/native/ingest.js +20 -25
  45. package/dist/runtime/native/sessions.d.ts +4 -4
  46. package/dist/runtime/native/sessions.js +12 -22
  47. package/dist/runtime/native/transcript.d.ts +3 -0
  48. package/dist/runtime/native/transcript.js +6 -5
  49. package/dist/runtime/native/translate.d.ts +2 -0
  50. package/dist/runtime/native/translate.js +34 -1
  51. package/dist/runtime/native/version.js +1 -1
  52. package/dist/runtime/presentation.js +2 -1
  53. package/dist/runtime/projection-store.js +2 -12
  54. package/dist/runtime/supervisor.d.ts +4 -4
  55. package/dist/runtime/supervisor.js +1 -1
  56. package/dist/runtime/turn-projection.d.ts +1 -1
  57. package/dist/runtime/turn-projection.js +2 -2
  58. package/package.json +2 -2
  59. package/dist/runtime/harness.d.ts +0 -26
  60. package/dist/runtime/harness.js +0 -142
@@ -1,15 +1,16 @@
1
- import type { RuntimeExecutionEvent, RuntimeTurnInput } from "@neta-art/cohub";
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 { type CodexShared } from "./codex-link.js";
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 { type PiLink } from "./pi-link.js";
7
+ import type { PiLink } from "./pi-link.js";
8
8
  import type { ExecutionResults } from "./results.js";
9
- import type { NativeSession, NativeSessions } from "./sessions.js";
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: HarnessOptions;
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
- export type HarnessResult = {
23
- session: NativeSession;
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 { harnessCommand, imageForPi } from "../harness.js";
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 { openPrivateServer } from "./codex-link.js";
7
- import { PI_DISCONNECTED } from "./pi-link.js";
8
- import { codexTranslator, piTranslator } from "./translate.js";
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 === "pi" ? runPi : runCodex)(executor, turn, context);
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, Codex
6
- * Turn ids are globally unique (a fork shares its history's Turns) and Pi entry ids are per
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: "pi" | "codex", nativeSessionId: string, turnKey: string): string;
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, Codex
10
- * Turn ids are globally unique (a fork shares its history's Turns) and Pi entry ids are per
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 === "pi" ? ["native", spaceId, "pi", nativeSessionId, turnKey] : ["native", spaceId, "codex", turnKey]));
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 Pi session's file as last written at `mtimeMs`: `true` when the
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
- piSettled?: (nativeSessionId: string, mtimeMs: number) => boolean | null;
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 { ensurePlainCodexRollout } from "./transcript.js";
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 Pi transcript whose last reply ended is settled after this long. */
16
- const PI_QUIET_SETTLE_MS = 10_000;
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: this.states.size,
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 + BOUNDARY_OVERLAP, 1024 * 1024));
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 - BOUNDARY_OVERLAP);
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 BOUNDARIES[harness]) {
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
- if (state.harness === "codex")
562
+ const format = transcriptFormat(state.harness);
563
+ if (format.turnEndRecorded)
570
564
  return tail.result !== null;
571
- const certain = this.options.hooks?.piSettled?.(state.nativeSessionId, mtimeMs);
565
+ const certain = this.options.hooks?.settled?.(state, mtimeMs);
572
566
  if (certain != null)
573
567
  return certain;
574
- if (!PI_ENDED.includes(tail.messages.at(-1)?.stopReason ?? ""))
568
+ if (!format.replyEnded?.(tail))
575
569
  return false;
576
570
  const idleMs = Date.now() - mtimeMs;
577
- if (idleMs >= PI_QUIET_SETTLE_MS)
571
+ if (idleMs >= QUIET_SETTLE_MS)
578
572
  return true;
579
- this.recheck(state.path, PI_QUIET_SETTLE_MS - idleMs);
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 === "codex" && !turn.userFinal)
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: state.harness === "codex" ? await ensurePlainCodexRollout(source, join(this.options.root, "native", "rollouts")) : source,
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: "pi" | "codex";
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: "pi" | "codex";
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: "pi" | "codex";
36
+ harness: LocalHarness;
37
37
  cwd: string;
38
38
  provider?: string | null;
39
39
  context: RuntimeContext;