@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,159 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { createConnection, createServer } from "node:net";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { withRuntimeSpaceBindingsLock } from "./space-binding.js";
7
+ export function runtimeInstanceDirectory(identity, spaceId) {
8
+ const key = createHash("sha256").update(`${identity}\0${spaceId}`).digest("hex").slice(0, 24);
9
+ return join(homedir(), ".local", "state", "cohub", "instances", key);
10
+ }
11
+ function alive(pid) {
12
+ if (!Number.isSafeInteger(pid) || pid <= 0)
13
+ return true;
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ return error.code !== "ESRCH";
20
+ }
21
+ }
22
+ async function readRecord(directory) {
23
+ try {
24
+ const value = JSON.parse(await readFile(join(directory, "owner.json"), "utf8"));
25
+ if (!Number.isSafeInteger(value.pid) || typeof value.nonce !== "string" || typeof value.socket !== "string")
26
+ throw new Error("Invalid Runtime instance record");
27
+ return value;
28
+ }
29
+ catch (error) {
30
+ if (error.code === "ENOENT")
31
+ return null;
32
+ throw error;
33
+ }
34
+ }
35
+ function request(record, action, force = false) {
36
+ return new Promise((resolve, reject) => {
37
+ const socket = createConnection(record.socket);
38
+ const timer = setTimeout(() => { socket.destroy(); reject(new Error("Runtime control timed out")); }, 3000);
39
+ let buffer = "";
40
+ let settled = false;
41
+ const finish = (error, value) => {
42
+ if (settled)
43
+ return;
44
+ settled = true;
45
+ clearTimeout(timer);
46
+ socket.destroy();
47
+ if (error)
48
+ reject(error);
49
+ else if (value)
50
+ resolve(value);
51
+ };
52
+ socket.on("connect", () => socket.write(`${JSON.stringify({ nonce: record.nonce, action, force })}\n`));
53
+ socket.on("error", (error) => finish(error));
54
+ socket.on("close", () => finish(new Error("Runtime control closed")));
55
+ socket.on("data", (chunk) => {
56
+ buffer += chunk.toString();
57
+ if (buffer.length > 64 * 1024) {
58
+ finish(new Error("Runtime response too large"));
59
+ return;
60
+ }
61
+ if (!buffer.includes("\n"))
62
+ return;
63
+ try {
64
+ const response = JSON.parse(buffer.slice(0, buffer.indexOf("\n")));
65
+ if (response.error)
66
+ finish(new Error(response.error));
67
+ else if (response.nonce !== record.nonce || response.status?.pid !== record.pid)
68
+ finish(new Error("Runtime identity changed"));
69
+ else
70
+ finish(undefined, response.status);
71
+ }
72
+ catch (error) {
73
+ finish(error instanceof Error ? error : new Error(String(error)));
74
+ }
75
+ });
76
+ });
77
+ }
78
+ export async function requestRuntimeInstance(directory, action = "status", force = false) {
79
+ const record = await readRecord(directory);
80
+ if (!record || !alive(record.pid))
81
+ return null;
82
+ try {
83
+ return await request(record, action, force);
84
+ }
85
+ catch (error) {
86
+ // A reused PID is not proof of ownership. Missing/refused endpoints cannot
87
+ // belong to a serving Runtime; timeouts and permission errors remain uncertain.
88
+ if (["ENOENT", "ECONNREFUSED"].includes(error.code ?? ""))
89
+ return null;
90
+ throw error;
91
+ }
92
+ }
93
+ /** Private local IPC is both the single-instance guard and the control surface. */
94
+ export async function ownRuntimeInstance(directory, status, stop) {
95
+ await mkdir(directory, { recursive: true, mode: 0o700 });
96
+ return withRuntimeSpaceBindingsLock(async () => {
97
+ if (await requestRuntimeInstance(directory))
98
+ throw new Error("Runtime already running; use status");
99
+ const socketPath = process.platform === "win32"
100
+ ? `\\\\.\\pipe\\cohub-${createHash("sha256").update(directory).digest("hex").slice(0, 24)}`
101
+ : join(directory, "control.sock");
102
+ if (process.platform !== "win32")
103
+ await rm(socketPath, { force: true });
104
+ const record = { pid: process.pid, nonce: randomUUID(), socket: socketPath };
105
+ const clients = new Set();
106
+ const server = createServer((socket) => {
107
+ clients.add(socket);
108
+ socket.setTimeout(3000, () => socket.destroy());
109
+ socket.on("error", () => socket.destroy());
110
+ socket.on("close", () => clients.delete(socket));
111
+ let input = "";
112
+ socket.on("data", (chunk) => {
113
+ input += chunk.toString();
114
+ if (input.length > 4096) {
115
+ socket.destroy();
116
+ return;
117
+ }
118
+ if (!input.includes("\n"))
119
+ return;
120
+ socket.removeAllListeners("data");
121
+ void (async () => {
122
+ const message = JSON.parse(input.slice(0, input.indexOf("\n")));
123
+ if (message.nonce !== record.nonce) {
124
+ socket.destroy();
125
+ return;
126
+ }
127
+ if (message.action === "stop")
128
+ await stop(message.force === true);
129
+ else if (message.action !== "status")
130
+ throw new Error("Unknown Runtime control request");
131
+ socket.end(`${JSON.stringify({ nonce: record.nonce, status: status() })}\n`);
132
+ })().catch((error) => socket.end(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`));
133
+ });
134
+ });
135
+ await new Promise((resolve, reject) => {
136
+ server.once("error", reject);
137
+ server.listen(socketPath, () => { server.removeListener("error", reject); resolve(); });
138
+ });
139
+ try {
140
+ if (process.platform !== "win32")
141
+ await chmod(socketPath, 0o600);
142
+ const temporary = join(directory, `${record.nonce}.tmp`);
143
+ await writeFile(temporary, JSON.stringify(record), { mode: 0o600 });
144
+ await rename(temporary, join(directory, "owner.json"));
145
+ }
146
+ catch (error) {
147
+ server.close();
148
+ throw error;
149
+ }
150
+ return async () => {
151
+ for (const client of clients)
152
+ client.destroy();
153
+ await new Promise((resolve) => server.close(() => resolve()));
154
+ // Never remove a replacement owner's files.
155
+ if ((await readRecord(directory))?.nonce === record.nonce)
156
+ await rm(join(directory, "owner.json"));
157
+ };
158
+ }, { path: join(directory, "owner.json") });
159
+ }
@@ -17,6 +17,8 @@ export declare function harnessEnvironment(): NodeJS.ProcessEnv;
17
17
  export declare class JsonRpcProcess {
18
18
  private mode;
19
19
  private child;
20
+ /** Process-group id of the harness process, for deferred cleanup confirmation. */
21
+ get processGroupId(): number | null;
20
22
  private pending;
21
23
  private listeners;
22
24
  private failureListeners;
@@ -50,6 +50,8 @@ export function harnessEnvironment() {
50
50
  export class JsonRpcProcess {
51
51
  mode;
52
52
  child;
53
+ /** Process-group id of the harness process, for deferred cleanup confirmation. */
54
+ get processGroupId() { return this.child.pid ?? null; }
53
55
  pending = new Map();
54
56
  listeners = new Set();
55
57
  failureListeners = new Set();
@@ -0,0 +1,20 @@
1
+ import type { Command } from "commander";
2
+ import { type RuntimeSummary } from "./presentation.js";
3
+ import { type RuntimeLaunch } from "./supervisor.js";
4
+ export type RuntimeUpOptions = {
5
+ space?: string;
6
+ new?: boolean;
7
+ name?: string;
8
+ harness: string[];
9
+ pi?: string;
10
+ codex?: string;
11
+ yes?: boolean;
12
+ json?: boolean;
13
+ detach?: boolean;
14
+ verbose?: boolean;
15
+ };
16
+ export declare const resolveLocalSpaceName: (root: string, name?: string) => string;
17
+ export declare function parseRuntimeHarnesses(values: string[]): ("pi" | "codex")[];
18
+ export declare function resolveRuntimeTarget(program: Command, target?: string): Promise<string>;
19
+ export declare function startBackgroundRuntime(config: RuntimeLaunch): Promise<RuntimeSummary>;
20
+ export declare function runtimeUp(program: Command, dir: string | undefined, options: RuntimeUpOptions): Promise<void>;
@@ -0,0 +1,176 @@
1
+ import { fork } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { stat } from "node:fs/promises";
4
+ import { basename, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { isLocalHarness } from "@neta-art/cohub";
7
+ import { discoverHarnesses, installedHarnesses } from "./harness.js";
8
+ import { requireAccessToken } from "../auth.js";
9
+ import { createClient } from "../client.js";
10
+ import { currentIdentityKey, explicitSpace } from "../space.js";
11
+ import { canonicalRuntimeRoot, getRuntimeSpaceBinding, resolveRuntimeSpace } from "./space-binding.js";
12
+ import { requestRuntimeInstance, runtimeInstanceDirectory } from "./instance.js";
13
+ import { createDiagnosticConsole, printRuntimeSummary, runtimeWebUrl } from "./presentation.js";
14
+ import { runRuntime } from "./supervisor.js";
15
+ export const resolveLocalSpaceName = (root, name) => name?.trim() || basename(root) || "local-space";
16
+ export function parseRuntimeHarnesses(values) {
17
+ const names = values.flatMap((value) => value.split(",")).map((name) => name.trim()).filter(Boolean);
18
+ if (names.some((name) => !isLocalHarness(name)))
19
+ throw new Error("Harness must be pi or codex");
20
+ return [...new Set(names.length ? names : ["pi"])];
21
+ }
22
+ export async function resolveRuntimeTarget(program, target) {
23
+ const spaceId = target?.trim() || explicitSpace(program) || (await getRuntimeSpaceBinding(process.cwd(), currentIdentityKey()))?.spaceId;
24
+ if (!spaceId)
25
+ throw new Error("No directory binding. Use --space <id> or runtime up");
26
+ return spaceId;
27
+ }
28
+ export async function startBackgroundRuntime(config) {
29
+ return new Promise((resolve, reject) => {
30
+ // Keep the same Node executable and loader (also works from source in tests).
31
+ const workerUrl = new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js", import.meta.url);
32
+ const child = fork(workerUrl, [], { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"] });
33
+ const diagnosticConsole = createDiagnosticConsole(config.verbose);
34
+ let last = null;
35
+ let settled = false;
36
+ const finish = (error) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ clearTimeout(timeout);
41
+ child.removeAllListeners("message");
42
+ // Let already queued IPC messages drain before closing Node's channel.
43
+ setImmediate(() => { if (child.connected)
44
+ child.disconnect(); child.unref(); });
45
+ if (error)
46
+ reject(error);
47
+ else if (last)
48
+ resolve(last);
49
+ else
50
+ reject(new Error("Runtime did not start"));
51
+ };
52
+ const timeout = setTimeout(() => finish(last ? undefined : new Error("Runtime startup timed out")), 30_000);
53
+ child.on("error", (error) => finish(error));
54
+ child.on("exit", (code) => finish(new Error(`Runtime exited (${code})`)));
55
+ child.on("message", (message) => {
56
+ if (message.type === "status" && message.status) {
57
+ last = message.status;
58
+ if (last.state === "ready")
59
+ finish();
60
+ }
61
+ else if (message.type === "failed")
62
+ finish(new Error(message.error ?? "Runtime failed"));
63
+ else if (message.type === "diagnostic" && message.event)
64
+ diagnosticConsole(message.event);
65
+ });
66
+ child.send(config);
67
+ });
68
+ }
69
+ export async function runtimeUp(program, dir, options) {
70
+ const requestedRoot = resolve(dir ?? process.cwd());
71
+ if (!(await stat(requestedRoot)).isDirectory())
72
+ throw new Error("Workspace is not a directory");
73
+ const root = await canonicalRuntimeRoot(requestedRoot);
74
+ const requested = options.space?.trim() || explicitSpace(program);
75
+ if (options.new && requested)
76
+ throw new Error("--new cannot be combined with --space or COHUB_SPACE_ID");
77
+ if (options.name && requested)
78
+ throw new Error("--name only applies to a new Space");
79
+ const identity = currentIdentityKey();
80
+ if (!identity) {
81
+ await requireAccessToken();
82
+ throw new Error("Cannot identify the signed-in account");
83
+ }
84
+ const binding = await getRuntimeSpaceBinding(root, identity);
85
+ const existingId = requested || binding?.spaceId;
86
+ let harnesses = parseRuntimeHarnesses(options.harness);
87
+ if (existingId && !options.new) {
88
+ const existing = await requestRuntimeInstance(runtimeInstanceDirectory(identity, existingId));
89
+ if (existing) {
90
+ if (existing.root !== root || options.harness.length && [...existing.harnesses].sort().join() !== [...harnesses].sort().join() || options.pi || options.codex) {
91
+ throw new Error("Runtime is running with a different configuration. Use down first");
92
+ }
93
+ printRuntimeSummary(existing, options.json, true);
94
+ return;
95
+ }
96
+ }
97
+ if (!options.harness.length)
98
+ harnesses = await installedHarnesses(root, options);
99
+ if (!harnesses.length)
100
+ throw new Error("Install and sign in to Pi or Codex, or pass --harness");
101
+ let createNew = Boolean(options.new);
102
+ let name = resolveLocalSpaceName(root, options.name);
103
+ if (!options.yes) {
104
+ if (!process.stdin.isTTY)
105
+ throw new Error("Use --yes to authorize local execution");
106
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
107
+ try {
108
+ if (!requested && binding) {
109
+ process.stderr.write(`\nLinked Space\n ${runtimeWebUrl(binding.spaceId)}\n`);
110
+ const answer = (await rl.question("Reuse this Space? [Y/n, q to cancel] ")).trim().toLowerCase();
111
+ if (answer === "q")
112
+ return;
113
+ createNew = answer === "n" || answer === "no";
114
+ }
115
+ else if (!requested) {
116
+ const answer = (await rl.question("Create a new Space? [Y/n] ")).trim().toLowerCase();
117
+ if (answer && answer !== "y" && answer !== "yes")
118
+ return;
119
+ }
120
+ if (binding && createNew && !options.name)
121
+ name = `${name}-${randomUUID().slice(0, 6)}`;
122
+ if (!requested && (!binding || createNew))
123
+ name = (await rl.question(`Space name [${name}]: `)).trim() || name;
124
+ process.stderr.write(`\nDirectory ${root}\n${requested ? `Space ${runtimeWebUrl(requested)}\n` : ""}`);
125
+ const answer = await rl.question("Collaborators can execute as your OS user, beyond this folder. Allow? [y/N] ");
126
+ if (!/^y(es)?$/i.test(answer.trim()))
127
+ return;
128
+ }
129
+ finally {
130
+ rl.close();
131
+ }
132
+ }
133
+ if (options.yes && createNew && binding && !options.name)
134
+ name = `${name}-${randomUUID().slice(0, 6)}`;
135
+ if (createNew && binding && await requestRuntimeInstance(runtimeInstanceDirectory(identity, binding.spaceId))) {
136
+ throw new Error("Stop the existing Runtime before rebinding this directory");
137
+ }
138
+ // Fail local preflight before creating remote state; the worker reuses this catalog.
139
+ const capabilities = await discoverHarnesses(harnesses, options, root);
140
+ const client = createClient();
141
+ const { spaceId, source } = await resolveRuntimeSpace({
142
+ root, identityKey: identity, explicitSpaceId: requested,
143
+ newSpace: createNew, expectedSpaceId: binding?.spaceId ?? null,
144
+ createSpace: async () => (await client.spaces.create({ name, config: { sandbox: { provider: "local" } } })).space.id,
145
+ validateSpace: async (id) => {
146
+ const sandbox = (await client.space(id).sandbox.get()).sandbox;
147
+ if (sandbox?.provider !== "local")
148
+ throw new Error("Space does not have a local Runtime");
149
+ },
150
+ });
151
+ const config = { spaceId, root, identity, harnesses, capabilities, executables: { pi: options.pi, codex: options.codex }, background: Boolean(options.detach), verbose: options.verbose };
152
+ const existing = await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId));
153
+ if (existing) {
154
+ if (existing.root !== root)
155
+ throw new Error("This Space is running in another directory");
156
+ printRuntimeSummary(existing, options.json, true);
157
+ return;
158
+ }
159
+ if (options.detach) {
160
+ const summary = await startBackgroundRuntime(config);
161
+ printRuntimeSummary(summary, options.json, source === "binding");
162
+ if (summary.state !== "ready")
163
+ process.exitCode = 2;
164
+ }
165
+ else {
166
+ let announced = false;
167
+ await runRuntime(config, (status) => {
168
+ if (!announced && status.state === "ready") {
169
+ announced = true;
170
+ printRuntimeSummary(status, options.json, source === "binding");
171
+ }
172
+ else if (!announced && status.state === "starting" && !options.json)
173
+ process.stderr.write(`Connecting\n ${runtimeWebUrl(spaceId)}\n Logs ${status.diagnosticsPath}\n`);
174
+ });
175
+ }
176
+ }
@@ -0,0 +1 @@
1
+ export declare function runCodexNativeHook(payload: unknown): Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { requestNativeDaemon } from "./native-ipc.js";
3
+ export async function runCodexNativeHook(payload) {
4
+ if (process.env.COHUB_TURN_ID || process.env.COHUB_EXECUTION_TOKEN)
5
+ return;
6
+ const value = payload;
7
+ if (!value || typeof value.cwd !== "string" || typeof value.session_id !== "string")
8
+ throw new Error("Invalid Codex hook identity");
9
+ if (typeof value.transcript_path !== "string" || !value.transcript_path)
10
+ return;
11
+ const result = await requestNativeDaemon({ harness: "codex", cwd: value.cwd, path: value.transcript_path, nativeSessionId: value.session_id });
12
+ if (!result.ok)
13
+ throw new Error(result.message);
14
+ }
15
+ async function main() {
16
+ let input = "";
17
+ for await (const chunk of process.stdin) {
18
+ input += chunk.toString();
19
+ if (Buffer.byteLength(input) > 4 * 1024 * 1024)
20
+ throw new Error("Codex hook input is too large");
21
+ }
22
+ await runCodexNativeHook(JSON.parse(input));
23
+ }
24
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
25
+ void main().catch((error) => {
26
+ process.stderr.write(`Cohub sync pending; native execution continues: ${error instanceof Error ? error.message : String(error)}\n`);
27
+ });
28
+ }
@@ -0,0 +1,21 @@
1
+ export declare function codexNativeHookBlock(node: string, hook: string): string;
2
+ export declare function verifyNativeSyncSupport(harnesses: ("pi" | "codex")[], cwd: string, executables?: {
3
+ pi?: string;
4
+ codex?: string;
5
+ }): Promise<void>;
6
+ /** Install once in the user's native configuration; data collection remains explicitly project-scoped. */
7
+ export declare function installNativeSync(input: {
8
+ root: string;
9
+ spaceId: string;
10
+ identity: string;
11
+ harnesses: ("pi" | "codex")[];
12
+ disabled?: boolean;
13
+ executables?: {
14
+ pi?: string;
15
+ codex?: string;
16
+ };
17
+ }): Promise<{
18
+ configPath: string;
19
+ harnesses: ("codex" | "pi")[];
20
+ enabled: boolean;
21
+ }>;
@@ -0,0 +1,130 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import { atomicRuntimeJson } from "./archive-store.js";
9
+ import { nativeRuntimeRoot, nativeSyncConfigPath, readNativeSyncConfig } from "./native-sync.js";
10
+ import { withRuntimeSpaceBindingsLock } from "./space-binding.js";
11
+ const START = "# BEGIN COHUB NATIVE SYNC";
12
+ const END = "# END COHUB NATIVE SYNC";
13
+ const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
14
+ const missing = (error) => error.code === "ENOENT";
15
+ export function codexNativeHookBlock(node, hook) {
16
+ const command = `${shellQuote(node)} ${shellQuote(hook)}`;
17
+ return `${START}\n${["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "Interrupt", "SessionEnd"].map((event) => `[[hooks.${event}]]\n[[hooks.${event}.hooks]]\ntype = "command"\ncommand = ${JSON.stringify(command)}\ntimeout = ${event === "SessionEnd" || event === "Interrupt" ? 3 : 10}\n`).join("\n")}${END}\n`;
18
+ }
19
+ async function installText(path, update) {
20
+ await withRuntimeSpaceBindingsLock(async () => {
21
+ const info = await lstat(path).catch((error) => { if (missing(error))
22
+ return null; throw error; });
23
+ if (info && (!info.isFile() || info.isSymbolicLink()))
24
+ throw new Error(`Refusing to replace a non-regular file: ${path}`);
25
+ const original = info ? await readFile(path, "utf8") : null;
26
+ const next = update(original);
27
+ if (next === original)
28
+ return;
29
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
30
+ if (original !== null) {
31
+ const backup = `${path}.cohub-backup-${createHash("sha256").update(original).digest("hex").slice(0, 16)}`;
32
+ const file = await open(backup, "wx", 0o600).catch((error) => { if (error.code === "EEXIST")
33
+ return null; throw error; });
34
+ if (file) {
35
+ try {
36
+ await file.writeFile(original);
37
+ await file.sync();
38
+ }
39
+ finally {
40
+ await file.close();
41
+ }
42
+ }
43
+ }
44
+ const temporary = `${path}.${randomUUID()}.tmp`;
45
+ try {
46
+ const file = await open(temporary, "wx", info?.mode ?? 0o600);
47
+ try {
48
+ await file.writeFile(next);
49
+ await file.sync();
50
+ }
51
+ finally {
52
+ await file.close();
53
+ }
54
+ const current = await readFile(path, "utf8").catch((error) => { if (missing(error))
55
+ return null; throw error; });
56
+ if (current !== original)
57
+ throw new Error(`Configuration changed during installation: ${path}`);
58
+ await rename(temporary, path);
59
+ const directory = await open(dirname(path), "r");
60
+ try {
61
+ await directory.sync();
62
+ }
63
+ finally {
64
+ await directory.close();
65
+ }
66
+ }
67
+ finally {
68
+ await rm(temporary, { force: true });
69
+ }
70
+ }, { lockPath: `${path}.cohub-lock` });
71
+ }
72
+ export async function verifyNativeSyncSupport(harnesses, cwd, executables = {}) {
73
+ for (const harness of harnesses) {
74
+ const { stdout } = await promisify(execFile)(executables[harness] || harness, harness === "pi" ? ["--version"] : ["features", "list"], { cwd, encoding: "utf8", timeout: 15_000, maxBuffer: 1024 * 1024 });
75
+ if (harness === "pi") {
76
+ const version = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(stdout);
77
+ if (!version || Number(version[1]) === 0 && (Number(version[2]) < 85 || Number(version[2]) === 85 && Number(version[3]) < 1))
78
+ throw new Error("Native sync requires Pi 0.85.1+");
79
+ }
80
+ else if (!/^hooks\s+stable\s+true\s*$/m.test(stdout))
81
+ throw new Error("Install a Codex version with stable Hooks and enable hooks first");
82
+ }
83
+ }
84
+ /** Install once in the user's native configuration; data collection remains explicitly project-scoped. */
85
+ export async function installNativeSync(input) {
86
+ if (!input.disabled)
87
+ await verifyNativeSyncSupport(input.harnesses, input.root, input.executables);
88
+ const runtimeRoot = nativeRuntimeRoot(input.spaceId);
89
+ const configPath = nativeSyncConfigPath(runtimeRoot, input.identity);
90
+ const extension = new URL(import.meta.url.endsWith(".ts") ? "./native-pi-extension.ts" : "./native-pi-extension.js", import.meta.url);
91
+ const hook = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./native-codex-hook.ts" : "./native-codex-hook.js", import.meta.url));
92
+ if (!input.disabled)
93
+ for (const harness of input.harnesses) {
94
+ if (harness === "pi") {
95
+ const content = `// Cohub native Turn sync\nexport { default } from ${JSON.stringify(extension.href)};\n`;
96
+ await installText(join(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"), "extensions", "cohub.ts"), (existing) => {
97
+ if (existing !== null && existing !== content)
98
+ throw new Error("Pi Cohub extension already exists; preserve it and review manually");
99
+ return content;
100
+ });
101
+ }
102
+ else {
103
+ const block = codexNativeHookBlock(process.execPath, hook);
104
+ await installText(join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "config.toml"), (existing) => {
105
+ if (existing?.includes(block))
106
+ return existing;
107
+ if (existing?.includes(START) || existing?.includes(END))
108
+ throw new Error("Codex Cohub hook block differs; preserve it and review manually");
109
+ if (existing && /^\s*hooks\s*=/m.test(existing))
110
+ throw new Error("Inline Codex hooks require manual merging");
111
+ return `${existing ?? ""}${existing?.endsWith("\n") ? "\n" : "\n\n"}${block}`;
112
+ });
113
+ }
114
+ }
115
+ await withRuntimeSpaceBindingsLock(async () => {
116
+ const previous = await readNativeSyncConfig(runtimeRoot, input.identity);
117
+ if (previous && previous.root !== input.root)
118
+ throw new Error("Space native sync belongs to another directory");
119
+ const harnesses = new Set(previous?.harnesses ?? []);
120
+ for (const harness of input.harnesses) {
121
+ if (input.disabled)
122
+ harnesses.delete(harness);
123
+ else
124
+ harnesses.add(harness);
125
+ }
126
+ const config = { version: 1, identity: input.identity, spaceId: input.spaceId, root: input.root, harnesses: [...harnesses] };
127
+ await atomicRuntimeJson(configPath, config);
128
+ }, { path: configPath });
129
+ return { configPath, harnesses: input.harnesses, enabled: !input.disabled };
130
+ }
@@ -0,0 +1,26 @@
1
+ import type { NativeSyncStore } from "./native-sync-store.js";
2
+ type NativeIpcRequest = {
3
+ type: "native.capture";
4
+ harness: "pi" | "codex";
5
+ cwd: string;
6
+ path: string;
7
+ nativeSessionId: string;
8
+ settled?: boolean;
9
+ leafId?: string | null;
10
+ };
11
+ type NativeIpcResponse = {
12
+ ok: true;
13
+ pendingTurns: number;
14
+ } | {
15
+ ok: false;
16
+ message: string;
17
+ };
18
+ export declare function nativeDaemonSocketFor(cwd: string): Promise<string | null>;
19
+ export declare function requestNativeDaemon(input: Omit<NativeIpcRequest, "type">): Promise<NativeIpcResponse>;
20
+ export declare function serveNativeDaemon(input: {
21
+ runtimeRoot: string;
22
+ handle: (request: NativeIpcRequest) => Promise<{
23
+ store: NativeSyncStore | null;
24
+ }>;
25
+ }): Promise<() => Promise<void>>;
26
+ export {};