@neta-art/cohub-cli 7.1.2 → 8.0.1

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 (48) hide show
  1. package/README.md +40 -2
  2. package/dist/auth.js +38 -5
  3. package/dist/client.js +5 -2
  4. package/dist/commands/runtime.d.ts +1 -2
  5. package/dist/commands/runtime.js +169 -293
  6. package/dist/commands/sandboxd-binary.d.ts +1 -1
  7. package/dist/commands/sandboxd-binary.js +13 -5
  8. package/dist/runtime/archive-store.d.ts +5 -1
  9. package/dist/runtime/archive-store.js +22 -6
  10. package/dist/runtime/connection.d.ts +4 -2
  11. package/dist/runtime/connection.js +113 -44
  12. package/dist/runtime/diagnostics.d.ts +3 -0
  13. package/dist/runtime/diagnostics.js +3 -0
  14. package/dist/runtime/harness.d.ts +7 -0
  15. package/dist/runtime/harness.js +63 -17
  16. package/dist/runtime/instance.d.ts +5 -0
  17. package/dist/runtime/instance.js +159 -0
  18. package/dist/runtime/json-rpc.d.ts +2 -0
  19. package/dist/runtime/json-rpc.js +2 -0
  20. package/dist/runtime/launch.d.ts +20 -0
  21. package/dist/runtime/launch.js +176 -0
  22. package/dist/runtime/native-codex-hook.d.ts +1 -0
  23. package/dist/runtime/native-codex-hook.js +28 -0
  24. package/dist/runtime/native-install.d.ts +21 -0
  25. package/dist/runtime/native-install.js +130 -0
  26. package/dist/runtime/native-ipc.d.ts +26 -0
  27. package/dist/runtime/native-ipc.js +101 -0
  28. package/dist/runtime/native-pi-extension.d.ts +20 -0
  29. package/dist/runtime/native-pi-extension.js +47 -0
  30. package/dist/runtime/native-sync-store.d.ts +97 -0
  31. package/dist/runtime/native-sync-store.js +365 -0
  32. package/dist/runtime/native-sync.d.ts +25 -0
  33. package/dist/runtime/native-sync.js +128 -0
  34. package/dist/runtime/native-transcript.d.ts +27 -0
  35. package/dist/runtime/native-transcript.js +281 -0
  36. package/dist/runtime/presentation.d.ts +21 -0
  37. package/dist/runtime/presentation.js +78 -0
  38. package/dist/runtime/process-group.d.ts +2 -0
  39. package/dist/runtime/process-group.js +124 -31
  40. package/dist/runtime/session-store.d.ts +17 -2
  41. package/dist/runtime/session-store.js +118 -9
  42. package/dist/runtime/space-binding.d.ts +3 -0
  43. package/dist/runtime/space-binding.js +43 -6
  44. package/dist/runtime/supervisor.d.ts +16 -0
  45. package/dist/runtime/supervisor.js +277 -0
  46. package/dist/runtime/worker.d.ts +1 -0
  47. package/dist/runtime/worker.js +20 -0
  48. package/package.json +3 -2
@@ -0,0 +1,101 @@
1
+ import { createConnection, createServer } from "node:net";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { nativeRuntimeRoot } from "./native-sync.js";
5
+ import { currentIdentityKey } from "../space.js";
6
+ import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
7
+ const MAX_LINE_BYTES = 4 * 1024 * 1024;
8
+ const socketPath = (runtimeRoot) => join(runtimeRoot, "native", "daemon.sock");
9
+ const parse = (raw) => {
10
+ const value = JSON.parse(raw);
11
+ if (value?.type !== "native.capture" || !["pi", "codex"].includes(value.harness)
12
+ || typeof value.cwd !== "string" || typeof value.path !== "string" || typeof value.nativeSessionId !== "string") {
13
+ throw new Error("Invalid native daemon request");
14
+ }
15
+ return value;
16
+ };
17
+ export async function nativeDaemonSocketFor(cwd) {
18
+ const identity = currentIdentityKey();
19
+ if (!identity)
20
+ return null;
21
+ const root = await canonicalRuntimeRoot(cwd);
22
+ const binding = await getRuntimeSpaceBinding(root, identity);
23
+ return binding ? socketPath(nativeRuntimeRoot(binding.spaceId)) : null;
24
+ }
25
+ export async function requestNativeDaemon(input) {
26
+ const path = await nativeDaemonSocketFor(input.cwd);
27
+ if (!path)
28
+ return { ok: false, message: "Native Runtime is not bound" };
29
+ return new Promise((resolve, reject) => {
30
+ const socket = createConnection(path);
31
+ let buffer = "";
32
+ const timer = setTimeout(() => { socket.destroy(); reject(new Error("Native Runtime daemon timed out")); }, 15_000);
33
+ const finish = (error, result) => {
34
+ clearTimeout(timer);
35
+ socket.destroy();
36
+ if (error)
37
+ reject(error);
38
+ else
39
+ resolve(result ?? { ok: false, message: "Empty daemon response" });
40
+ };
41
+ socket.once("error", (error) => finish(error));
42
+ socket.on("data", (chunk) => {
43
+ buffer += chunk.toString();
44
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) {
45
+ finish(new Error("Native daemon response too large"));
46
+ return;
47
+ }
48
+ const newline = buffer.indexOf("\n");
49
+ if (newline < 0)
50
+ return;
51
+ try {
52
+ finish(undefined, JSON.parse(buffer.slice(0, newline)));
53
+ }
54
+ catch (error) {
55
+ finish(error instanceof Error ? error : new Error(String(error)));
56
+ }
57
+ });
58
+ socket.once("connect", () => socket.write(`${JSON.stringify({ type: "native.capture", ...input })}\n`));
59
+ });
60
+ }
61
+ export async function serveNativeDaemon(input) {
62
+ const path = socketPath(input.runtimeRoot);
63
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
64
+ await rm(path, { force: true });
65
+ const server = createServer((socket) => {
66
+ let buffer = "";
67
+ let closed = false;
68
+ const respond = (value) => {
69
+ if (closed)
70
+ return;
71
+ closed = true;
72
+ socket.end(`${JSON.stringify(value)}\n`);
73
+ };
74
+ socket.setTimeout(20_000, () => socket.destroy());
75
+ socket.once("error", () => { closed = true; });
76
+ socket.on("data", (chunk) => {
77
+ buffer += chunk.toString();
78
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) {
79
+ socket.destroy();
80
+ return;
81
+ }
82
+ const newline = buffer.indexOf("\n");
83
+ if (newline < 0)
84
+ return;
85
+ socket.removeAllListeners("data");
86
+ void (async () => {
87
+ const request = parse(buffer.slice(0, newline));
88
+ const result = await input.handle(request);
89
+ respond({ ok: true, pendingTurns: result.store ? (await result.store.status()).pendingTurns : 0 });
90
+ })().catch((error) => respond({ ok: false, message: error instanceof Error ? error.message : String(error) }));
91
+ });
92
+ });
93
+ await new Promise((resolve, reject) => {
94
+ server.once("error", reject);
95
+ server.listen(path, () => { server.removeListener("error", reject); resolve(); });
96
+ });
97
+ return async () => {
98
+ await new Promise((resolve) => server.close(() => resolve()));
99
+ await rm(path, { force: true });
100
+ };
101
+ }
@@ -0,0 +1,20 @@
1
+ type PiContext = {
2
+ cwd: string;
3
+ sessionManager: {
4
+ getSessionFile(): string | undefined;
5
+ getSessionId(): string;
6
+ getLeafId(): string | null;
7
+ };
8
+ abort(): void;
9
+ isIdle(): boolean;
10
+ ui: {
11
+ notify(message: string, level: "info" | "warning" | "error"): void;
12
+ setStatus(key: string, value: string | undefined): void;
13
+ };
14
+ };
15
+ type PiExtension = {
16
+ on(event: string, handler: (event: unknown, context: PiContext) => Promise<void> | void): void;
17
+ };
18
+ /** Thin native adapter. The Runtime Daemon owns Cohub auth, WS, retries and archives. */
19
+ export default function cohubNativeExtension(pi: PiExtension): void;
20
+ export {};
@@ -0,0 +1,47 @@
1
+ import { requestNativeDaemon } from "./native-ipc.js";
2
+ /** Thin native adapter. The Runtime Daemon owns Cohub auth, WS, retries and archives. */
3
+ export default function cohubNativeExtension(pi) {
4
+ if (process.env.COHUB_TURN_ID || process.env.COHUB_EXECUTION_TOKEN)
5
+ return;
6
+ let context = null;
7
+ let captures = Promise.resolve();
8
+ let lastError = "";
9
+ let timer;
10
+ const report = (error) => {
11
+ const message = error instanceof Error ? error.message : String(error);
12
+ if (message !== lastError)
13
+ context?.ui.notify(`Cohub sync pending: ${message}`, "warning");
14
+ lastError = message;
15
+ };
16
+ const capture = (ctx, settled = false) => {
17
+ context = ctx;
18
+ const path = ctx.sessionManager.getSessionFile();
19
+ if (!path)
20
+ return Promise.resolve();
21
+ captures = captures.then(async () => {
22
+ const result = await requestNativeDaemon({ harness: "pi", cwd: ctx.cwd, path, nativeSessionId: ctx.sessionManager.getSessionId(), leafId: ctx.sessionManager.getLeafId(), settled });
23
+ if (!result.ok)
24
+ throw new Error(result.message);
25
+ ctx.ui.setStatus("cohub", "Cohub");
26
+ lastError = "";
27
+ }).catch(report);
28
+ return captures;
29
+ };
30
+ pi.on("session_start", async (_event, ctx) => {
31
+ await capture(ctx, ctx.isIdle());
32
+ if (timer)
33
+ clearInterval(timer);
34
+ timer = setInterval(() => { void capture(ctx, ctx.isIdle()); }, 5000);
35
+ timer.unref();
36
+ });
37
+ pi.on("message_end", (_event, ctx) => capture(ctx));
38
+ pi.on("agent_settled", (_event, ctx) => capture(ctx, true));
39
+ pi.on("session_shutdown", async (_event, ctx) => {
40
+ if (timer)
41
+ clearInterval(timer);
42
+ timer = undefined;
43
+ await captures;
44
+ ctx.ui.setStatus("cohub", undefined);
45
+ context = null;
46
+ });
47
+ }
@@ -0,0 +1,97 @@
1
+ import type { NativeTurnBinding, NativeTurnComplete, NativeTurnStart, NativeTurnProgress } from "@neta-art/cohub";
2
+ import { RuntimeArchiveStore, type ArchiveTransport } from "./archive-store.js";
3
+ import type { NativeTranscript } from "./native-transcript.js";
4
+ export declare const nativeIdentityHash: (identity: string) => string;
5
+ export declare function nativeStableId(value: string): string;
6
+ type NativeBinding = {
7
+ version: 1;
8
+ identity: string;
9
+ spaceId: string;
10
+ harness: "pi" | "codex";
11
+ nativeSessionId: string;
12
+ instanceKey?: string;
13
+ path: string;
14
+ originSessionId: string;
15
+ sessionId: string | null;
16
+ throughTurnId: string | null;
17
+ throughBytes: number;
18
+ anchors: Array<{
19
+ turnId: string;
20
+ sizeBytes: number;
21
+ sha256: string;
22
+ }>;
23
+ };
24
+ export type NativeTurnReceipt = {
25
+ version: 1;
26
+ turnId: string;
27
+ key: string;
28
+ parentKey: string | null;
29
+ parentCloudTurnId: string | null;
30
+ userContent: NativeTurnStart["userContent"];
31
+ startedAt: string;
32
+ endBytes: number;
33
+ contentEndBytes?: number;
34
+ result: NativeTurnComplete | null;
35
+ progress?: NativeTurnProgress;
36
+ };
37
+ export type NativeSyncTransport = ArchiveTransport & {
38
+ startNativeTurn?(input: NativeTurnStart, options?: {
39
+ signal?: AbortSignal;
40
+ }): Promise<NativeTurnBinding>;
41
+ completeNativeTurn?(sessionId: string, turnId: string, input: NativeTurnComplete, options?: {
42
+ signal?: AbortSignal;
43
+ }): Promise<{
44
+ completed: true;
45
+ artifactsPending?: boolean;
46
+ }>;
47
+ heartbeatNativeTurn?(sessionId: string, turnId: string, options?: {
48
+ signal?: AbortSignal;
49
+ }): Promise<{
50
+ abortRequested: boolean;
51
+ status: string;
52
+ }>;
53
+ updateNativeTurn?(sessionId: string, turnId: string, input: NativeTurnProgress, options?: {
54
+ signal?: AbortSignal;
55
+ }): Promise<{
56
+ accepted: boolean;
57
+ }>;
58
+ };
59
+ export type NativeSyncOptions = {
60
+ runtimeRoot: string;
61
+ spaceId: string;
62
+ identity: string;
63
+ harness: "pi" | "codex";
64
+ nativeSessionId: string;
65
+ instanceKey?: string;
66
+ transport?: NativeSyncTransport;
67
+ };
68
+ /** Turn receipts are append-only. Capture never waits for the network; network ACKs live in separate files. */
69
+ export declare class NativeSyncStore {
70
+ readonly options: NativeSyncOptions;
71
+ readonly root: string;
72
+ readonly archives: RuntimeArchiveStore;
73
+ private archiveFailure;
74
+ constructor(options: NativeSyncOptions);
75
+ private bindingPath;
76
+ private turnId;
77
+ private receiptPath;
78
+ private acknowledgementPath;
79
+ private pendingPath;
80
+ private requestPath;
81
+ private cloudBindingPath;
82
+ binding(): Promise<NativeBinding>;
83
+ private initialize;
84
+ capture(path: string, transcript: NativeTranscript): Promise<void>;
85
+ receipts(pendingOnly?: boolean): Promise<NativeTurnReceipt[]>;
86
+ status(): Promise<{
87
+ harness: "codex" | "pi";
88
+ nativeSessionId: string;
89
+ sessionId: string | null;
90
+ pendingTurns: number;
91
+ pendingArchives: number;
92
+ }>;
93
+ flush(signal: AbortSignal, onAbort?: () => void): Promise<void>;
94
+ private cloudArchive;
95
+ }
96
+ export declare function listNativeSyncStores(runtimeRoot: string, spaceId: string, identity: string, transport?: NativeSyncTransport): Promise<NativeSyncStore[]>;
97
+ export {};
@@ -0,0 +1,365 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { mkdir, readFile, readdir, stat, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { nativeTurnCompleteSchema, nativeTurnStartSchema, nativeTurnProgressSchema, harnessArchiveIndexSchema } from "@neta-art/cohub";
6
+ import { RuntimeArchiveStore, atomicRuntimeJson } from "./archive-store.js";
7
+ import { findRuntimeNativeSession } from "./session-store.js";
8
+ import { withRuntimeSpaceBindingsLock } from "./space-binding.js";
9
+ const missing = (error) => error.code === "ENOENT";
10
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
11
+ export const nativeIdentityHash = (identity) => hash(identity);
12
+ export function nativeStableId(value) {
13
+ const hex = hash(value);
14
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
15
+ }
16
+ async function readJson(path) {
17
+ try {
18
+ return JSON.parse(await readFile(path, "utf8"));
19
+ }
20
+ catch (error) {
21
+ if (missing(error))
22
+ return null;
23
+ throw error;
24
+ }
25
+ }
26
+ /** Turn receipts are append-only. Capture never waits for the network; network ACKs live in separate files. */
27
+ export class NativeSyncStore {
28
+ options;
29
+ root;
30
+ archives;
31
+ archiveFailure;
32
+ constructor(options) {
33
+ this.options = options;
34
+ const source = options.instanceKey ? JSON.stringify([options.nativeSessionId, options.instanceKey]) : options.nativeSessionId;
35
+ this.root = join(options.runtimeRoot, "native", nativeIdentityHash(options.identity), `${options.harness}-${hash(source)}`);
36
+ const transport = options.transport;
37
+ this.archives = new RuntimeArchiveStore(join(this.root, "archives"), transport ? {
38
+ prepareRuntimeArchive: async (index, request) => transport.prepareRuntimeArchive(await this.cloudArchive(index), request),
39
+ commitRuntimeArchive: async (index, request) => transport.commitRuntimeArchive(await this.cloudArchive(index), request),
40
+ getRuntimeArchive: (...args) => transport.getRuntimeArchive(...args),
41
+ fetchObject: transport.fetchObject,
42
+ } : undefined);
43
+ this.archives.setErrorReporter((error) => { this.archiveFailure = error; });
44
+ }
45
+ bindingPath() { return join(this.root, "binding.json"); }
46
+ turnId(key) {
47
+ return nativeStableId(JSON.stringify([this.options.identity, this.options.spaceId, this.options.harness, this.options.nativeSessionId, this.options.instanceKey ?? null, key]));
48
+ }
49
+ receiptPath(id) { return join(this.root, "turns", `${id}.json`); }
50
+ acknowledgementPath(id) { return join(this.root, "acknowledged", `${id}.json`); }
51
+ pendingPath(id) { return join(this.root, "pending", `${id}.json`); }
52
+ requestPath(id) { return join(this.root, "requests", `${id}.json`); }
53
+ cloudBindingPath(id) { return join(this.root, "bindings", `${id}.json`); }
54
+ async binding() {
55
+ const value = await readJson(this.bindingPath());
56
+ if (value?.version !== 1 || value.identity !== this.options.identity || value.spaceId !== this.options.spaceId || value.nativeSessionId !== this.options.nativeSessionId || value.instanceKey !== this.options.instanceKey || value.harness !== this.options.harness)
57
+ throw new Error("Native binding mismatch");
58
+ return value;
59
+ }
60
+ async initialize(path, transcript) {
61
+ const existing = await readJson(this.bindingPath());
62
+ if (existing) {
63
+ const binding = await this.binding();
64
+ if (binding.path !== path)
65
+ throw new Error("Native path changed; original binding retained");
66
+ return binding;
67
+ }
68
+ const managed = await findRuntimeNativeSession(this.options.runtimeRoot, this.options.harness, transcript.nativeSessionId, path);
69
+ let throughBytes = 0;
70
+ const anchors = [];
71
+ if (managed) {
72
+ if (managed.pendingTurnId)
73
+ throw new Error("Reconcile the managed Turn before native continuation");
74
+ if (managed.throughTurnId) {
75
+ const index = await readJson(join(this.options.runtimeRoot, "archives", "versions", `${managed.throughTurnId}.json`));
76
+ if (index) {
77
+ const parsed = harnessArchiveIndexSchema.parse(index);
78
+ const checksum = createHash("sha256");
79
+ for await (const bytes of createReadStream(path, { end: parsed.sizeBytes - 1 }))
80
+ checksum.update(bytes);
81
+ if (checksum.digest("hex") !== parsed.sha256)
82
+ throw new Error("Runtime history prefix changed");
83
+ throughBytes = parsed.sizeBytes;
84
+ }
85
+ else {
86
+ const checksum = createHash("sha256");
87
+ for await (const bytes of createReadStream(path))
88
+ checksum.update(bytes);
89
+ if (checksum.digest("hex") !== managed.checksum)
90
+ throw new Error("Cannot identify the last complete Runtime Turn");
91
+ throughBytes = (await stat(path)).size;
92
+ anchors.push({ turnId: managed.throughTurnId, sizeBytes: throughBytes, sha256: managed.checksum });
93
+ }
94
+ const versions = join(this.options.runtimeRoot, "archives", "versions");
95
+ const names = await readdir(versions).catch((error) => { if (missing(error))
96
+ return []; throw error; });
97
+ for (const name of names) {
98
+ if (!name.endsWith(".json"))
99
+ continue;
100
+ const version = harnessArchiveIndexSchema.parse(await readJson(join(versions, name)));
101
+ if (version.sessionId === managed.sessionId && version.harness === managed.harness && version.nativeSessionId === managed.nativeSessionId && version.sizeBytes <= throughBytes) {
102
+ anchors.push({ turnId: version.turnId, sizeBytes: version.sizeBytes, sha256: version.sha256 });
103
+ }
104
+ }
105
+ }
106
+ }
107
+ const binding = { version: 1, identity: this.options.identity, spaceId: this.options.spaceId, harness: this.options.harness,
108
+ nativeSessionId: transcript.nativeSessionId, instanceKey: this.options.instanceKey, path, originSessionId: nativeStableId(`${this.root}:archive`),
109
+ sessionId: managed?.sessionId ?? transcript.cloudSessionId ?? null, throughTurnId: managed?.throughTurnId ?? null, throughBytes, anchors };
110
+ // A managed Runtime must rebuild a separate projection rather than write into an interactive client's file.
111
+ await atomicRuntimeJson(join(this.options.runtimeRoot, "native-owners", `${hash(path)}.json`), { path, nativeSessionId: binding.nativeSessionId });
112
+ await atomicRuntimeJson(this.bindingPath(), binding);
113
+ return binding;
114
+ }
115
+ async capture(path, transcript) {
116
+ if (transcript.nativeSessionId !== this.options.nativeSessionId)
117
+ throw new Error("Native session identity mismatch");
118
+ await withRuntimeSpaceBindingsLock(async () => {
119
+ const binding = await this.initialize(path, transcript);
120
+ if (binding.throughBytes > 0) {
121
+ const anchor = binding.anchors.find((entry) => entry.sizeBytes === binding.throughBytes);
122
+ if (!anchor || transcript.prefixes.get(binding.throughBytes) !== anchor.sha256)
123
+ throw new Error("Runtime history prefix changed; original binding retained");
124
+ }
125
+ let parentKey = null;
126
+ let parentCloudTurnId = binding.throughTurnId;
127
+ let knownBoundary = binding.throughBytes === 0;
128
+ for (const turn of transcript.turns) {
129
+ if (turn.startBytes < binding.throughBytes) {
130
+ // Native offsets only validate whole-Turn archive checkpoints; they never become cloud fork anchors.
131
+ const matches = (binding.anchors ?? []).filter((anchor) => anchor.sizeBytes >= turn.contentEndBytes && turn.boundaries[anchor.sizeBytes] === anchor.sha256);
132
+ if (new Set(matches.map((anchor) => anchor.turnId)).size > 1)
133
+ throw new Error("Ambiguous Runtime Turn boundary");
134
+ parentCloudTurnId = matches[0]?.turnId ?? null;
135
+ knownBoundary = matches.length > 0;
136
+ parentKey = null;
137
+ continue;
138
+ }
139
+ if (turn.cloudTurnId) {
140
+ parentCloudTurnId = turn.cloudTurnId;
141
+ knownBoundary = binding.harness === "codex" ? turn.result !== null : !["toolUse", "pending"].includes(turn.messages.at(-1)?.stopReason ?? "pending");
142
+ parentKey = null;
143
+ continue;
144
+ }
145
+ if (!parentKey && !knownBoundary)
146
+ throw new Error("Native continuation is not at a complete Runtime Turn boundary");
147
+ const turnId = this.turnId(turn.key);
148
+ const old = await readJson(this.receiptPath(turnId));
149
+ if (old?.result) {
150
+ if (turn.contentEndBytes < (old.contentEndBytes ?? old.endBytes) || JSON.stringify(turn.userContent) !== JSON.stringify(old.userContent) || turn.result && JSON.stringify(nativeTurnCompleteSchema.parse(turn.result)) !== JSON.stringify(old.result)) {
151
+ throw new Error("Native branch is inside a settled Turn; only whole-Turn forks are supported");
152
+ }
153
+ parentKey = turn.key;
154
+ continue;
155
+ }
156
+ const result = turn.result ? nativeTurnCompleteSchema.parse(turn.result) : null;
157
+ const receipt = { version: 1, turnId, key: turn.key, parentKey, parentCloudTurnId: parentKey ? null : parentCloudTurnId,
158
+ userContent: turn.userContent, startedAt: turn.startedAt, endBytes: turn.endBytes, contentEndBytes: turn.contentEndBytes, result,
159
+ ...(!result ? { progress: nativeTurnProgressSchema.parse({ revision: turn.endBytes, messages: turn.messages }) } : {}) };
160
+ if (old && (JSON.stringify(old.userContent) !== JSON.stringify(receipt.userContent) || old.parentKey !== receipt.parentKey))
161
+ throw new Error("Native Turn changed; original receipt retained");
162
+ // Capture immutable native bytes before publishing the completed receipt. Subsequent Turns may change the source.
163
+ if (result)
164
+ await this.archives.stage({ sessionId: binding.originSessionId, harness: binding.harness, nativeSessionId: binding.nativeSessionId, path, sizeBytes: turn.endBytes, expectedChecksum: turn.sha256 }, turnId);
165
+ if (JSON.stringify(old) !== JSON.stringify(receipt)) {
166
+ // The pending index precedes the receipt, so a crash cannot silently strand an unacknowledged Turn.
167
+ await atomicRuntimeJson(this.pendingPath(turnId), { turnId });
168
+ await atomicRuntimeJson(this.receiptPath(turnId), receipt);
169
+ }
170
+ parentKey = turn.key;
171
+ }
172
+ }, { lockPath: join(this.root, "capture.lock") });
173
+ }
174
+ async receipts(pendingOnly = false) {
175
+ const names = await readdir(join(this.root, pendingOnly ? "pending" : "turns")).catch((error) => { if (missing(error))
176
+ return []; throw error; });
177
+ const receipts = [];
178
+ for (const name of names) {
179
+ if (!name.endsWith(".json"))
180
+ continue;
181
+ const receipt = await readJson(join(this.root, "turns", name));
182
+ if (pendingOnly && !receipt) {
183
+ // Crash window: the pending index was written but the receipt itself never landed.
184
+ // The pointer carries no data; drop it — the next capture rebuilds the receipt from the transcript.
185
+ await rm(join(this.root, "pending", name), { force: true });
186
+ continue;
187
+ }
188
+ if (receipt?.version !== 1 || receipt.turnId !== this.turnId(receipt.key))
189
+ throw new Error("Native receipt is corrupt; original retained");
190
+ receipts.push(receipt);
191
+ }
192
+ return receipts.sort((a, b) => a.endBytes - b.endBytes || a.turnId.localeCompare(b.turnId));
193
+ }
194
+ async status() {
195
+ const binding = await this.binding();
196
+ const receipts = await this.receipts();
197
+ let pendingTurns = 0;
198
+ let sessionId = binding.sessionId;
199
+ for (const receipt of receipts) {
200
+ const remote = await readJson(this.cloudBindingPath(receipt.turnId));
201
+ if (remote)
202
+ sessionId = remote.sessionId;
203
+ if (!await readJson(this.acknowledgementPath(receipt.turnId)))
204
+ pendingTurns++;
205
+ }
206
+ return { harness: binding.harness, nativeSessionId: binding.nativeSessionId, sessionId, pendingTurns, pendingArchives: await this.archives.pendingCount() };
207
+ }
208
+ async flush(signal, onAbort) {
209
+ const transport = this.options.transport;
210
+ if (!transport)
211
+ return;
212
+ await withRuntimeSpaceBindingsLock(async () => {
213
+ const binding = await this.binding();
214
+ const pending = new Map((await this.receipts(true)).map((receipt) => [receipt.key, receipt]));
215
+ const processed = new Set();
216
+ const visit = async (receipt) => {
217
+ signal.throwIfAborted();
218
+ if (await readJson(this.acknowledgementPath(receipt.turnId))) {
219
+ await rm(this.pendingPath(receipt.turnId), { force: true });
220
+ return true;
221
+ }
222
+ if (processed.has(receipt.key))
223
+ return false;
224
+ processed.add(receipt.key);
225
+ let parent = null;
226
+ if (receipt.parentKey) {
227
+ const parentId = this.turnId(receipt.parentKey);
228
+ parent = await readJson(this.acknowledgementPath(parentId));
229
+ if (!parent) {
230
+ const predecessor = pending.get(receipt.parentKey);
231
+ if (!predecessor || !await visit(predecessor))
232
+ return false;
233
+ parent = await readJson(this.acknowledgementPath(parentId));
234
+ }
235
+ if (!parent)
236
+ throw new Error("Parent binding is missing");
237
+ }
238
+ let request = await readJson(this.requestPath(receipt.turnId));
239
+ if (!request) {
240
+ request = nativeTurnStartSchema.parse({ turnId: receipt.turnId, sessionId: parent?.sessionId ?? binding.sessionId, parentTurnId: parent?.turnId ?? receipt.parentCloudTurnId,
241
+ branchSessionId: nativeStableId(`${receipt.turnId}:branch`), harness: binding.harness, nativeSessionId: binding.nativeSessionId, userContent: receipt.userContent, startedAt: receipt.startedAt });
242
+ await atomicRuntimeJson(this.requestPath(receipt.turnId), request);
243
+ }
244
+ let remote = await readJson(this.cloudBindingPath(receipt.turnId));
245
+ if (!remote) {
246
+ if (!transport.startNativeTurn)
247
+ throw new Error("Native Runtime WS is unavailable");
248
+ remote = await transport.startNativeTurn(request, { signal });
249
+ if (remote.turnId !== receipt.turnId)
250
+ throw new Error("Server Turn identity mismatch");
251
+ await atomicRuntimeJson(this.cloudBindingPath(receipt.turnId), remote);
252
+ }
253
+ if (!receipt.result) {
254
+ if (receipt.progress?.messages.length && transport.updateNativeTurn) {
255
+ const progressPath = join(this.root, "progress", `${receipt.turnId}.json`);
256
+ const sent = await readJson(progressPath);
257
+ if (!sent || sent.revision < receipt.progress.revision) {
258
+ await transport.updateNativeTurn(remote.sessionId, remote.turnId, receipt.progress, { signal });
259
+ await atomicRuntimeJson(progressPath, { revision: receipt.progress.revision });
260
+ }
261
+ }
262
+ if (onAbort && transport.heartbeatNativeTurn) {
263
+ const status = await transport.heartbeatNativeTurn(remote.sessionId, remote.turnId, { signal });
264
+ if (status.abortRequested)
265
+ onAbort?.();
266
+ }
267
+ return false;
268
+ }
269
+ if (!transport.completeNativeTurn)
270
+ throw new Error("Native Runtime WS is unavailable");
271
+ // Artifact retries back off: the terminal state is durable, so hammering the completion
272
+ // endpoint every flush cycle (5s) while object storage is down only adds load.
273
+ const backoffPath = join(this.root, "backoff", `${receipt.turnId}.json`);
274
+ const backoff = await readJson(backoffPath);
275
+ if (backoff && Date.now() - backoff.at < 30_000)
276
+ return false;
277
+ const completion = await transport.completeNativeTurn(remote.sessionId, remote.turnId, receipt.result, { signal });
278
+ if (completion.artifactsPending) {
279
+ // Terminal state is durable; only the artifact snapshot is missing. Keep the receipt pending
280
+ // and retry on the next flush cycle (>= 30s) until artifacts persist.
281
+ await atomicRuntimeJson(backoffPath, { at: Date.now() });
282
+ throw new Error("Native artifacts are pending; completion replays later");
283
+ }
284
+ await rm(backoffPath, { force: true });
285
+ await atomicRuntimeJson(this.acknowledgementPath(receipt.turnId), remote);
286
+ await rm(this.pendingPath(receipt.turnId), { force: true });
287
+ return true;
288
+ };
289
+ const failures = [];
290
+ for (const receipt of pending.values()) {
291
+ if (!processed.has(receipt.key)) {
292
+ try {
293
+ await visit(receipt);
294
+ }
295
+ catch (error) {
296
+ failures.push(error);
297
+ }
298
+ }
299
+ }
300
+ // Resolve Session identities before the archive dependency walk. A fork baseline must not
301
+ // wait for an unrelated parent Session's failed upload. Immutable local versions stay untouched.
302
+ const archivePending = join(this.archives.root, "pending");
303
+ const indexes = await readdir(archivePending).catch((error) => { if (missing(error))
304
+ return []; throw error; });
305
+ for (const name of indexes) {
306
+ if (!name.endsWith(".json"))
307
+ continue;
308
+ try {
309
+ const path = join(archivePending, name);
310
+ const index = harnessArchiveIndexSchema.parse(await readJson(path));
311
+ const resolved = await this.cloudArchive(index);
312
+ if (JSON.stringify(index) !== JSON.stringify(resolved))
313
+ await atomicRuntimeJson(path, resolved);
314
+ }
315
+ catch (error) {
316
+ failures.push(error);
317
+ }
318
+ }
319
+ this.archiveFailure = undefined;
320
+ await this.archives.flush(signal);
321
+ if (failures.length)
322
+ throw failures[0];
323
+ if (this.archiveFailure)
324
+ throw this.archiveFailure;
325
+ }, { lockPath: join(this.root, "flush.lock") });
326
+ }
327
+ async cloudArchive(index) {
328
+ const binding = await readJson(this.acknowledgementPath(index.turnId));
329
+ if (!binding)
330
+ throw new Error("Native Turn result is not confirmed");
331
+ if (index.parentTurnId) {
332
+ const parent = await readJson(this.acknowledgementPath(index.parentTurnId));
333
+ if (parent?.sessionId === binding.sessionId)
334
+ return { ...index, sessionId: binding.sessionId };
335
+ // Cross-Session archive parents are forbidden. Materialize a baseline using existing immutable segments.
336
+ const segments = [...index.segments];
337
+ const visited = new Set([index.turnId]);
338
+ let parentId = index.parentTurnId;
339
+ while (parentId) {
340
+ if (visited.has(parentId))
341
+ throw new Error("Cyclic native archive");
342
+ visited.add(parentId);
343
+ const previous = harnessArchiveIndexSchema.parse(await readJson(join(this.archives.root, "versions", `${parentId}.json`)));
344
+ segments.unshift(...previous.segments);
345
+ parentId = previous.parentTurnId;
346
+ }
347
+ return harnessArchiveIndexSchema.parse({ ...index, sessionId: binding.sessionId, parentTurnId: null, segments });
348
+ }
349
+ return { ...index, sessionId: binding.sessionId };
350
+ }
351
+ }
352
+ export async function listNativeSyncStores(runtimeRoot, spaceId, identity, transport) {
353
+ const root = join(runtimeRoot, "native", nativeIdentityHash(identity));
354
+ await mkdir(root, { recursive: true, mode: 0o700 });
355
+ const stores = [];
356
+ for (const name of await readdir(root)) {
357
+ if (!/^(pi|codex)-[a-f0-9]{64}$/.test(name))
358
+ continue;
359
+ const binding = await readJson(join(root, name, "binding.json"));
360
+ if (!binding || binding.identity !== identity || binding.spaceId !== spaceId)
361
+ continue;
362
+ stores.push(new NativeSyncStore({ runtimeRoot, spaceId, identity, harness: binding.harness, nativeSessionId: binding.nativeSessionId, instanceKey: binding.instanceKey, transport }));
363
+ }
364
+ return stores;
365
+ }
@@ -0,0 +1,25 @@
1
+ import { NativeSyncStore, type NativeSyncTransport } from "./native-sync-store.js";
2
+ import type { NativeRuntimeEvent } from "@neta-art/cohub";
3
+ export type NativeSyncConfig = {
4
+ version: 1;
5
+ identity: string;
6
+ spaceId: string;
7
+ root: string;
8
+ harnesses: ("pi" | "codex")[];
9
+ };
10
+ export declare const nativeRuntimeRoot: (spaceId: string) => string;
11
+ export declare const nativeSyncConfigPath: (runtimeRoot: string, identity: string) => string;
12
+ export declare function nativeArchiveTransport(spaceId: string, identity: string): Pick<NativeSyncTransport, "prepareRuntimeArchive" | "commitRuntimeArchive" | "getRuntimeArchive">;
13
+ export declare function readNativeSyncConfig(runtimeRoot: string, identity: string): Promise<NativeSyncConfig | null>;
14
+ /** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
15
+ export declare function captureNativeSession(input: {
16
+ harness: "pi" | "codex";
17
+ cwd: string;
18
+ path: string;
19
+ nativeSessionId?: string;
20
+ settled?: boolean;
21
+ leafId?: string | null;
22
+ }): Promise<NativeSyncStore | null>;
23
+ /** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
24
+ export declare function nativeWebSocketTransport(spaceId: string, identity: string, send: (event: NativeRuntimeEvent) => Promise<unknown>): NativeSyncTransport;
25
+ export declare function flushNativeSessions(spaceId: string, identity: string, signal: AbortSignal, report: (error: unknown) => void, transportOverride?: NativeSyncTransport): Promise<void>;