@neta-art/cohub-cli 7.1.2 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -2
- package/dist/auth.js +38 -5
- package/dist/client.js +5 -2
- package/dist/commands/runtime.d.ts +1 -2
- package/dist/commands/runtime.js +178 -302
- package/dist/commands/sandboxd-binary.d.ts +1 -1
- package/dist/commands/sandboxd-binary.js +7 -5
- package/dist/runtime/archive-store.d.ts +2 -0
- package/dist/runtime/archive-store.js +20 -6
- package/dist/runtime/connection.d.ts +4 -2
- package/dist/runtime/connection.js +80 -23
- package/dist/runtime/diagnostics.d.ts +3 -0
- package/dist/runtime/diagnostics.js +3 -0
- package/dist/runtime/harness.d.ts +3 -0
- package/dist/runtime/harness.js +34 -1
- package/dist/runtime/instance.d.ts +5 -0
- package/dist/runtime/instance.js +159 -0
- package/dist/runtime/launch.d.ts +20 -0
- package/dist/runtime/launch.js +176 -0
- package/dist/runtime/native-codex-hook.d.ts +1 -0
- package/dist/runtime/native-codex-hook.js +28 -0
- package/dist/runtime/native-install.d.ts +21 -0
- package/dist/runtime/native-install.js +130 -0
- package/dist/runtime/native-ipc.d.ts +26 -0
- package/dist/runtime/native-ipc.js +101 -0
- package/dist/runtime/native-pi-extension.d.ts +20 -0
- package/dist/runtime/native-pi-extension.js +47 -0
- package/dist/runtime/native-sync-store.d.ts +97 -0
- package/dist/runtime/native-sync-store.js +365 -0
- package/dist/runtime/native-sync.d.ts +25 -0
- package/dist/runtime/native-sync.js +128 -0
- package/dist/runtime/native-transcript.d.ts +27 -0
- package/dist/runtime/native-transcript.js +281 -0
- package/dist/runtime/presentation.d.ts +21 -0
- package/dist/runtime/presentation.js +76 -0
- package/dist/runtime/session-store.d.ts +2 -0
- package/dist/runtime/session-store.js +40 -5
- package/dist/runtime/space-binding.d.ts +3 -0
- package/dist/runtime/space-binding.js +43 -6
- package/dist/runtime/supervisor.d.ts +16 -0
- package/dist/runtime/supervisor.js +277 -0
- package/dist/runtime/worker.d.ts +1 -0
- package/dist/runtime/worker.js +20 -0
- package/package.json +3 -2
|
@@ -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 / Harness 必须是 pi 或 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 / 此目录未绑定 Space,请使用 --space <id> 或 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 / Runtime 未启动"));
|
|
51
|
+
};
|
|
52
|
+
const timeout = setTimeout(() => finish(last ? undefined : new Error("Runtime startup timed out / Runtime 启动超时")), 30_000);
|
|
53
|
+
child.on("error", (error) => finish(error));
|
|
54
|
+
child.on("exit", (code) => finish(new Error(`Runtime exited (${code}) / Runtime 已退出`)));
|
|
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 / --new 不能与显式 Space 同时使用");
|
|
77
|
+
if (options.name && requested)
|
|
78
|
+
throw new Error("--name only applies to a new Space / --name 仅用于新建 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 / Runtime 正使用不同配置运行,请先 down");
|
|
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 / 请安装并登录 Pi 或 Codex,或显式指定 --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 / 请使用 --yes 授权本地执行");
|
|
106
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
107
|
+
try {
|
|
108
|
+
if (!requested && binding) {
|
|
109
|
+
process.stderr.write(`\nLinked Space / 已关联 Space\n ${runtimeWebUrl(binding.spaceId)}\n`);
|
|
110
|
+
const answer = (await rl.question("Reuse this Space? [Y/n, q to cancel] / 复用此 Space?[Y/n,q 取消] ")).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] / 创建新 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 / Space 名称 [${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] / 协作者可使用你的系统身份执行命令,不限于此目录。允许?[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 / 请先停止此目录的 Runtime,再创建新绑定");
|
|
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 / 此 Space 不是本地 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 / 此 Space 已在另一目录运行");
|
|
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 / Codex Hook 身份无效");
|
|
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 / Codex Hook 输入过大");
|
|
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 / Cohub 待同步,原生执行继续: ${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+ / 原生同步需要 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 / 请安装支持稳定 Hooks 的 Codex 版本并启用 Hooks");
|
|
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 / Cohub 原生 Turn 同步\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 / Pi Cohub 扩展已存在,请保留并手动核对");
|
|
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 / Codex Cohub Hook 配置不同,请保留并手动核对");
|
|
109
|
+
if (existing && /^\s*hooks\s*=/m.test(existing))
|
|
110
|
+
throw new Error("Inline Codex hooks require manual merging / 内联 Codex Hooks 需要手动合并");
|
|
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 / 此 Space 原生同步属于其他目录");
|
|
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 {};
|
|
@@ -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 / 原生 Daemon 请求无效");
|
|
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 / 原生 Runtime 未绑定" };
|
|
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 / 原生 Runtime Daemon 超时")); }, 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 / Daemon 返回为空" });
|
|
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 / Cohub 待同步: ${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 {};
|