@neta-art/cohub-cli 8.0.0 → 8.1.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 +7 -16
- package/dist/commands/runtime.js +190 -87
- package/dist/commands/sandboxd-binary.d.ts +1 -1
- package/dist/commands/sandboxd-binary.js +8 -1
- package/dist/runtime/archive-store.d.ts +3 -1
- package/dist/runtime/archive-store.js +2 -0
- package/dist/runtime/connection.js +34 -22
- package/dist/runtime/diagnostics.js +8 -1
- package/dist/runtime/harness.d.ts +4 -0
- package/dist/runtime/harness.js +29 -16
- package/dist/runtime/instance.js +5 -5
- package/dist/runtime/json-rpc.d.ts +2 -0
- package/dist/runtime/json-rpc.js +2 -0
- package/dist/runtime/launch.js +29 -23
- package/dist/runtime/native-attach.d.ts +28 -0
- package/dist/runtime/native-attach.js +47 -0
- package/dist/runtime/native-codex-hook.js +3 -3
- package/dist/runtime/native-install.js +9 -9
- package/dist/runtime/native-ipc.d.ts +2 -0
- package/dist/runtime/native-ipc.js +7 -5
- package/dist/runtime/native-pi-extension.js +1 -1
- package/dist/runtime/native-sync-store.d.ts +6 -1
- package/dist/runtime/native-sync-store.js +24 -21
- package/dist/runtime/native-sync.d.ts +25 -0
- package/dist/runtime/native-sync.js +89 -8
- package/dist/runtime/native-transcript.d.ts +8 -0
- package/dist/runtime/native-transcript.js +45 -16
- package/dist/runtime/presentation.d.ts +10 -1
- package/dist/runtime/presentation.js +47 -29
- package/dist/runtime/process-group.d.ts +2 -0
- package/dist/runtime/process-group.js +124 -31
- package/dist/runtime/session-store.d.ts +15 -2
- package/dist/runtime/session-store.js +79 -5
- package/dist/runtime/space-binding.js +3 -3
- package/dist/runtime/supervisor.js +4 -5
- package/package.json +2 -2
|
@@ -21,6 +21,12 @@ function isRecord(value) {
|
|
|
21
21
|
function redactUrl(value) {
|
|
22
22
|
try {
|
|
23
23
|
const url = new URL(value);
|
|
24
|
+
// Redaction must not rewrite the string when there is nothing to hide;
|
|
25
|
+
// URL.toString() re-encodes (e.g. quotes from wrapped log lines) and
|
|
26
|
+
// makes healthy URLs look corrupted.
|
|
27
|
+
const sensitive = [...url.searchParams.keys()].some((key) => SENSITIVE_KEY.test(key));
|
|
28
|
+
if (!sensitive)
|
|
29
|
+
return value;
|
|
24
30
|
for (const key of [...url.searchParams.keys()]) {
|
|
25
31
|
if (SENSITIVE_KEY.test(key))
|
|
26
32
|
url.searchParams.set(key, "[REDACTED]");
|
|
@@ -37,7 +43,8 @@ export function redactDiagnosticString(value) {
|
|
|
37
43
|
: value;
|
|
38
44
|
return shortened
|
|
39
45
|
.replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [REDACTED]")
|
|
40
|
-
|
|
46
|
+
// Trailing quotes/brackets from wrapped log lines are not part of the URL.
|
|
47
|
+
.replace(/\b(rediss?|https?|wss?):\/\/[^\s"'<>]+/gi, (url) => redactUrl(url))
|
|
41
48
|
.replace(/\b(authorization|password|secret|token|access[_-]?key|refresh[_-]?token)([\s:=]+)([^\s,;]+)/gi, "$1$2[REDACTED]");
|
|
42
49
|
}
|
|
43
50
|
function redactValue(value, seen = new WeakSet(), depth = 0) {
|
|
@@ -14,6 +14,10 @@ export type HarnessResult = {
|
|
|
14
14
|
event: Extract<RuntimeExecutionEvent, {
|
|
15
15
|
type: "turn.end";
|
|
16
16
|
}>;
|
|
17
|
+
uncertainCleanup?: {
|
|
18
|
+
processGroupId: number;
|
|
19
|
+
error: string;
|
|
20
|
+
};
|
|
17
21
|
};
|
|
18
22
|
export declare function piContent(value: unknown): ContentBlock[];
|
|
19
23
|
export declare function promptText(content: ContentBlock[]): string;
|
package/dist/runtime/harness.js
CHANGED
|
@@ -2,6 +2,7 @@ import { access, stat } from "node:fs/promises";
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { delimiter, join, resolve, win32 } from "node:path";
|
|
4
4
|
import { JsonRpcProcess, record } from "./json-rpc.js";
|
|
5
|
+
import { ProcessCleanupUncertainError } from "./process-group.js";
|
|
5
6
|
import { codexModelCatalog } from "./model-catalog.js";
|
|
6
7
|
import { codexTokenTotals, codexUsage, subtractCodexTokens } from "./codex-usage.js";
|
|
7
8
|
import { downloadPublicImage } from "../safe-remote-image.js";
|
|
@@ -85,6 +86,16 @@ async function finishHarnessTurn(store, state, message, resume, turnId, diagnost
|
|
|
85
86
|
const archive = await store.archive(state, turnId, diagnosticContext).catch(() => null);
|
|
86
87
|
return { state, event: { type: "turn.end", message, resume, archive } };
|
|
87
88
|
}
|
|
89
|
+
/** Close the RPC process. An unconfirmable cleanup degrades the result to uncertain, never masks it. */
|
|
90
|
+
async function closeHarness(rpc, executionError) {
|
|
91
|
+
return rpc.close().then(() => undefined, (cleanupError) => {
|
|
92
|
+
const processGroupId = rpc.processGroupId;
|
|
93
|
+
if (!(cleanupError instanceof ProcessCleanupUncertainError) || processGroupId == null)
|
|
94
|
+
throw cleanupError;
|
|
95
|
+
const detail = executionError ? `; execution error: ${executionError}` : "";
|
|
96
|
+
return { processGroupId, error: `${cleanupError.message}${detail}` };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
88
99
|
export async function discoverHarnesses(harnesses, options, cwd) {
|
|
89
100
|
const models = [];
|
|
90
101
|
await Promise.all(harnesses.map(async (harness) => {
|
|
@@ -133,6 +144,7 @@ export async function executePi(input, options, cwd, store, emit, signal, diagno
|
|
|
133
144
|
let currentContent = [];
|
|
134
145
|
let last = { ordinal: 0, content: [] };
|
|
135
146
|
const abortEscalation = createAbortEscalation(rpc, signal, () => { void rpc.request("abort").catch(() => undefined); });
|
|
147
|
+
let uncertainCleanup;
|
|
136
148
|
const stopFailureLogging = diagnostics
|
|
137
149
|
? rpc.onFailure((error) => diagnostics.log("error", "harness.rpc_process_failed", { error: serializeDiagnosticError(error) }, { ...diagnosticContext, component: "harness" }))
|
|
138
150
|
: () => undefined;
|
|
@@ -235,17 +247,17 @@ export async function executePi(input, options, cwd, store, emit, signal, diagno
|
|
|
235
247
|
abortEscalation.clear();
|
|
236
248
|
stopFailureLogging();
|
|
237
249
|
stopTimeoutLogging();
|
|
238
|
-
await rpc.
|
|
250
|
+
uncertainCleanup = await closeHarness(rpc, last.errorMessage);
|
|
239
251
|
}
|
|
240
252
|
if (signal.aborted)
|
|
241
253
|
last = { ...last, stopReason: "aborted" };
|
|
242
|
-
return finishHarnessTurn(store, state, last, resume, input.turnId, {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
254
|
+
return { ...await finishHarnessTurn(store, state, last, resume, input.turnId, {
|
|
255
|
+
component: "harness",
|
|
256
|
+
sessionId: input.sessionId,
|
|
257
|
+
turnId: input.turnId,
|
|
258
|
+
harness: "pi",
|
|
259
|
+
traceContext: input.traceContext,
|
|
260
|
+
}), ...(uncertainCleanup ? { uncertainCleanup } : {}) };
|
|
249
261
|
}
|
|
250
262
|
export function codexItemContent(item) {
|
|
251
263
|
if (item.type === "agentMessage" || item.type === "plan")
|
|
@@ -295,6 +307,7 @@ export async function executeCodex(input, options, cwd, store, emit, signal, dia
|
|
|
295
307
|
if (nativeTurnId)
|
|
296
308
|
void rpc.request("turn/interrupt", { threadId: state.nativeSessionId, turnId: nativeTurnId }).catch(() => undefined);
|
|
297
309
|
});
|
|
310
|
+
let uncertainCleanup;
|
|
298
311
|
try {
|
|
299
312
|
await initializeCodex(rpc);
|
|
300
313
|
const threadOptions = {
|
|
@@ -451,17 +464,17 @@ export async function executeCodex(input, options, cwd, store, emit, signal, dia
|
|
|
451
464
|
abortEscalation.clear();
|
|
452
465
|
stopFailureLogging();
|
|
453
466
|
stopTimeoutLogging();
|
|
454
|
-
await rpc.
|
|
467
|
+
uncertainCleanup = await closeHarness(rpc, final.errorMessage);
|
|
455
468
|
}
|
|
456
469
|
if (signal.aborted)
|
|
457
470
|
final = { ...final, stopReason: "aborted" };
|
|
458
471
|
if (usage)
|
|
459
472
|
final = { ...final, usage };
|
|
460
|
-
return finishHarnessTurn(store, state, final, resume, input.turnId, {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
473
|
+
return { ...await finishHarnessTurn(store, state, final, resume, input.turnId, {
|
|
474
|
+
component: "harness",
|
|
475
|
+
sessionId: input.sessionId,
|
|
476
|
+
turnId: input.turnId,
|
|
477
|
+
harness: "codex",
|
|
478
|
+
traceContext: input.traceContext,
|
|
479
|
+
}), ...(uncertainCleanup ? { uncertainCleanup } : {}) };
|
|
467
480
|
}
|
package/dist/runtime/instance.js
CHANGED
|
@@ -23,7 +23,7 @@ async function readRecord(directory) {
|
|
|
23
23
|
try {
|
|
24
24
|
const value = JSON.parse(await readFile(join(directory, "owner.json"), "utf8"));
|
|
25
25
|
if (!Number.isSafeInteger(value.pid) || typeof value.nonce !== "string" || typeof value.socket !== "string")
|
|
26
|
-
throw new Error("Invalid Runtime instance record
|
|
26
|
+
throw new Error("Invalid Runtime instance record");
|
|
27
27
|
return value;
|
|
28
28
|
}
|
|
29
29
|
catch (error) {
|
|
@@ -35,7 +35,7 @@ async function readRecord(directory) {
|
|
|
35
35
|
function request(record, action, force = false) {
|
|
36
36
|
return new Promise((resolve, reject) => {
|
|
37
37
|
const socket = createConnection(record.socket);
|
|
38
|
-
const timer = setTimeout(() => { socket.destroy(); reject(new Error("Runtime control timed out
|
|
38
|
+
const timer = setTimeout(() => { socket.destroy(); reject(new Error("Runtime control timed out")); }, 3000);
|
|
39
39
|
let buffer = "";
|
|
40
40
|
let settled = false;
|
|
41
41
|
const finish = (error, value) => {
|
|
@@ -51,7 +51,7 @@ function request(record, action, force = false) {
|
|
|
51
51
|
};
|
|
52
52
|
socket.on("connect", () => socket.write(`${JSON.stringify({ nonce: record.nonce, action, force })}\n`));
|
|
53
53
|
socket.on("error", (error) => finish(error));
|
|
54
|
-
socket.on("close", () => finish(new Error("Runtime control closed
|
|
54
|
+
socket.on("close", () => finish(new Error("Runtime control closed")));
|
|
55
55
|
socket.on("data", (chunk) => {
|
|
56
56
|
buffer += chunk.toString();
|
|
57
57
|
if (buffer.length > 64 * 1024) {
|
|
@@ -65,7 +65,7 @@ function request(record, action, force = false) {
|
|
|
65
65
|
if (response.error)
|
|
66
66
|
finish(new Error(response.error));
|
|
67
67
|
else if (response.nonce !== record.nonce || response.status?.pid !== record.pid)
|
|
68
|
-
finish(new Error("Runtime identity changed
|
|
68
|
+
finish(new Error("Runtime identity changed"));
|
|
69
69
|
else
|
|
70
70
|
finish(undefined, response.status);
|
|
71
71
|
}
|
|
@@ -95,7 +95,7 @@ export async function ownRuntimeInstance(directory, status, stop) {
|
|
|
95
95
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
96
96
|
return withRuntimeSpaceBindingsLock(async () => {
|
|
97
97
|
if (await requestRuntimeInstance(directory))
|
|
98
|
-
throw new Error("Runtime already running; use status
|
|
98
|
+
throw new Error("Runtime already running; use status");
|
|
99
99
|
const socketPath = process.platform === "win32"
|
|
100
100
|
? `\\\\.\\pipe\\cohub-${createHash("sha256").update(directory).digest("hex").slice(0, 24)}`
|
|
101
101
|
: join(directory, "control.sock");
|
|
@@ -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;
|
package/dist/runtime/json-rpc.js
CHANGED
|
@@ -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();
|
package/dist/runtime/launch.js
CHANGED
|
@@ -10,19 +10,20 @@ import { createClient } from "../client.js";
|
|
|
10
10
|
import { currentIdentityKey, explicitSpace } from "../space.js";
|
|
11
11
|
import { canonicalRuntimeRoot, getRuntimeSpaceBinding, resolveRuntimeSpace } from "./space-binding.js";
|
|
12
12
|
import { requestRuntimeInstance, runtimeInstanceDirectory } from "./instance.js";
|
|
13
|
+
import { ensureNativeSync } from "./native-attach.js";
|
|
13
14
|
import { createDiagnosticConsole, printRuntimeSummary, runtimeWebUrl } from "./presentation.js";
|
|
14
15
|
import { runRuntime } from "./supervisor.js";
|
|
15
16
|
export const resolveLocalSpaceName = (root, name) => name?.trim() || basename(root) || "local-space";
|
|
16
17
|
export function parseRuntimeHarnesses(values) {
|
|
17
18
|
const names = values.flatMap((value) => value.split(",")).map((name) => name.trim()).filter(Boolean);
|
|
18
19
|
if (names.some((name) => !isLocalHarness(name)))
|
|
19
|
-
throw new Error("Harness must be pi or codex
|
|
20
|
+
throw new Error("Harness must be pi or codex");
|
|
20
21
|
return [...new Set(names.length ? names : ["pi"])];
|
|
21
22
|
}
|
|
22
23
|
export async function resolveRuntimeTarget(program, target) {
|
|
23
24
|
const spaceId = target?.trim() || explicitSpace(program) || (await getRuntimeSpaceBinding(process.cwd(), currentIdentityKey()))?.spaceId;
|
|
24
25
|
if (!spaceId)
|
|
25
|
-
throw new Error("No directory binding. Use --space <id> or runtime up
|
|
26
|
+
throw new Error("No directory binding. Use --space <id> or runtime up");
|
|
26
27
|
return spaceId;
|
|
27
28
|
}
|
|
28
29
|
export async function startBackgroundRuntime(config) {
|
|
@@ -47,11 +48,11 @@ export async function startBackgroundRuntime(config) {
|
|
|
47
48
|
else if (last)
|
|
48
49
|
resolve(last);
|
|
49
50
|
else
|
|
50
|
-
reject(new Error("Runtime did not start
|
|
51
|
+
reject(new Error("Runtime did not start"));
|
|
51
52
|
};
|
|
52
|
-
const timeout = setTimeout(() => finish(last ? undefined : new Error("Runtime startup timed out
|
|
53
|
+
const timeout = setTimeout(() => finish(last ? undefined : new Error("Runtime startup timed out")), 30_000);
|
|
53
54
|
child.on("error", (error) => finish(error));
|
|
54
|
-
child.on("exit", (code) => finish(new Error(`Runtime exited (${code})
|
|
55
|
+
child.on("exit", (code) => finish(new Error(`Runtime exited (${code})`)));
|
|
55
56
|
child.on("message", (message) => {
|
|
56
57
|
if (message.type === "status" && message.status) {
|
|
57
58
|
last = message.status;
|
|
@@ -69,17 +70,17 @@ export async function startBackgroundRuntime(config) {
|
|
|
69
70
|
export async function runtimeUp(program, dir, options) {
|
|
70
71
|
const requestedRoot = resolve(dir ?? process.cwd());
|
|
71
72
|
if (!(await stat(requestedRoot)).isDirectory())
|
|
72
|
-
throw new Error("Workspace is not a directory
|
|
73
|
+
throw new Error("Workspace is not a directory");
|
|
73
74
|
const root = await canonicalRuntimeRoot(requestedRoot);
|
|
74
75
|
const requested = options.space?.trim() || explicitSpace(program);
|
|
75
76
|
if (options.new && requested)
|
|
76
|
-
throw new Error("--new cannot be combined with --space or COHUB_SPACE_ID
|
|
77
|
+
throw new Error("--new cannot be combined with --space or COHUB_SPACE_ID");
|
|
77
78
|
if (options.name && requested)
|
|
78
|
-
throw new Error("--name only applies to a new Space
|
|
79
|
+
throw new Error("--name only applies to a new Space");
|
|
79
80
|
const identity = currentIdentityKey();
|
|
80
81
|
if (!identity) {
|
|
81
82
|
await requireAccessToken();
|
|
82
|
-
throw new Error("Cannot identify the signed-in account
|
|
83
|
+
throw new Error("Cannot identify the signed-in account");
|
|
83
84
|
}
|
|
84
85
|
const binding = await getRuntimeSpaceBinding(root, identity);
|
|
85
86
|
const existingId = requested || binding?.spaceId;
|
|
@@ -88,8 +89,11 @@ export async function runtimeUp(program, dir, options) {
|
|
|
88
89
|
const existing = await requestRuntimeInstance(runtimeInstanceDirectory(identity, existingId));
|
|
89
90
|
if (existing) {
|
|
90
91
|
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
|
+
throw new Error("Runtime is running with a different configuration. Use down first");
|
|
92
93
|
}
|
|
94
|
+
// `up` is the single idempotent entry: a reused instance adopts its running
|
|
95
|
+
// Harnesses (unless explicitly overridden) and completes native sync setup.
|
|
96
|
+
await ensureNativeSync({ root, spaceId: existing.spaceId, identity, harnesses: options.harness.length ? harnesses : parseRuntimeHarnesses(existing.harnesses), yes: options.yes, executables: { pi: options.pi, codex: options.codex } });
|
|
93
97
|
printRuntimeSummary(existing, options.json, true);
|
|
94
98
|
return;
|
|
95
99
|
}
|
|
@@ -97,33 +101,33 @@ export async function runtimeUp(program, dir, options) {
|
|
|
97
101
|
if (!options.harness.length)
|
|
98
102
|
harnesses = await installedHarnesses(root, options);
|
|
99
103
|
if (!harnesses.length)
|
|
100
|
-
throw new Error("Install and sign in to Pi or Codex, or pass --harness
|
|
104
|
+
throw new Error("Install and sign in to Pi or Codex, or pass --harness");
|
|
101
105
|
let createNew = Boolean(options.new);
|
|
102
106
|
let name = resolveLocalSpaceName(root, options.name);
|
|
103
107
|
if (!options.yes) {
|
|
104
108
|
if (!process.stdin.isTTY)
|
|
105
|
-
throw new Error("Use --yes to authorize local execution
|
|
109
|
+
throw new Error("Use --yes to authorize local execution");
|
|
106
110
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
107
111
|
try {
|
|
108
112
|
if (!requested && binding) {
|
|
109
|
-
process.stderr.write(`\nLinked Space
|
|
110
|
-
const answer = (await rl.question("Reuse this Space? [Y/n, q to cancel]
|
|
113
|
+
process.stderr.write(`\nLinked Space\n ${runtimeWebUrl(binding.spaceId)}\n`);
|
|
114
|
+
const answer = (await rl.question("Reuse this Space? [Y/n, q to cancel] ")).trim().toLowerCase();
|
|
111
115
|
if (answer === "q")
|
|
112
116
|
return;
|
|
113
117
|
createNew = answer === "n" || answer === "no";
|
|
114
118
|
}
|
|
115
119
|
else if (!requested) {
|
|
116
|
-
const answer = (await rl.question("Create a new Space? [Y/n]
|
|
120
|
+
const answer = (await rl.question("Create a new Space? [Y/n] ")).trim().toLowerCase();
|
|
117
121
|
if (answer && answer !== "y" && answer !== "yes")
|
|
118
122
|
return;
|
|
119
123
|
}
|
|
120
124
|
if (binding && createNew && !options.name)
|
|
121
125
|
name = `${name}-${randomUUID().slice(0, 6)}`;
|
|
122
126
|
if (!requested && (!binding || createNew))
|
|
123
|
-
name = (await rl.question(`Space name
|
|
124
|
-
process.stderr.write(`\nDirectory
|
|
125
|
-
const answer = await rl.question("Collaborators can execute as your OS user, beyond this folder. Allow? [
|
|
126
|
-
if (
|
|
127
|
+
name = (await rl.question(`Space name [${name}]: `)).trim() || name;
|
|
128
|
+
process.stderr.write(`\nDirectory ${root}\n${requested ? `Space ${runtimeWebUrl(requested)}\n` : ""}`);
|
|
129
|
+
const answer = await rl.question("Collaborators can execute as your OS user, beyond this folder. Allow? [Y/n] ");
|
|
130
|
+
if (/^n(o)?$/i.test(answer.trim()))
|
|
127
131
|
return;
|
|
128
132
|
}
|
|
129
133
|
finally {
|
|
@@ -133,7 +137,7 @@ export async function runtimeUp(program, dir, options) {
|
|
|
133
137
|
if (options.yes && createNew && binding && !options.name)
|
|
134
138
|
name = `${name}-${randomUUID().slice(0, 6)}`;
|
|
135
139
|
if (createNew && binding && await requestRuntimeInstance(runtimeInstanceDirectory(identity, binding.spaceId))) {
|
|
136
|
-
throw new Error("Stop the existing Runtime before rebinding this directory
|
|
140
|
+
throw new Error("Stop the existing Runtime before rebinding this directory");
|
|
137
141
|
}
|
|
138
142
|
// Fail local preflight before creating remote state; the worker reuses this catalog.
|
|
139
143
|
const capabilities = await discoverHarnesses(harnesses, options, root);
|
|
@@ -145,14 +149,16 @@ export async function runtimeUp(program, dir, options) {
|
|
|
145
149
|
validateSpace: async (id) => {
|
|
146
150
|
const sandbox = (await client.space(id).sandbox.get()).sandbox;
|
|
147
151
|
if (sandbox?.provider !== "local")
|
|
148
|
-
throw new Error("Space does not have a local Runtime
|
|
152
|
+
throw new Error("Space does not have a local Runtime");
|
|
149
153
|
},
|
|
150
154
|
});
|
|
155
|
+
// Consent (or --yes) precedes any user-level integration install; failure never blocks startup.
|
|
156
|
+
await ensureNativeSync({ root, spaceId, identity, harnesses, yes: options.yes, executables: { pi: options.pi, codex: options.codex } });
|
|
151
157
|
const config = { spaceId, root, identity, harnesses, capabilities, executables: { pi: options.pi, codex: options.codex }, background: Boolean(options.detach), verbose: options.verbose };
|
|
152
158
|
const existing = await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId));
|
|
153
159
|
if (existing) {
|
|
154
160
|
if (existing.root !== root)
|
|
155
|
-
throw new Error("This Space is running in another directory
|
|
161
|
+
throw new Error("This Space is running in another directory");
|
|
156
162
|
printRuntimeSummary(existing, options.json, true);
|
|
157
163
|
return;
|
|
158
164
|
}
|
|
@@ -170,7 +176,7 @@ export async function runtimeUp(program, dir, options) {
|
|
|
170
176
|
printRuntimeSummary(status, options.json, source === "binding");
|
|
171
177
|
}
|
|
172
178
|
else if (!announced && status.state === "starting" && !options.json)
|
|
173
|
-
process.stderr.write(`Connecting
|
|
179
|
+
process.stderr.write(`Connecting\n ${runtimeWebUrl(spaceId)}\n Logs ${status.diagnosticsPath}\n`);
|
|
174
180
|
});
|
|
175
181
|
}
|
|
176
182
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type NativeSyncConfig } from "./native-sync.js";
|
|
2
|
+
type Harness = "pi" | "codex";
|
|
3
|
+
export type NativeSyncReport = {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
harnesses: Harness[];
|
|
6
|
+
note?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Already enabled for this exact directory and every requested Harness: installation is idempotent. */
|
|
9
|
+
export declare function nativeSyncSatisfied(config: NativeSyncConfig | null, input: {
|
|
10
|
+
spaceId: string;
|
|
11
|
+
root: string;
|
|
12
|
+
harnesses: Harness[];
|
|
13
|
+
}): boolean;
|
|
14
|
+
/** One explicit consent prompt, defaulting to yes; native history may contain secrets. */
|
|
15
|
+
export declare function askNativeSyncConsent(harnesses: Harness[], ask?: (question: string) => Promise<string>): Promise<boolean>;
|
|
16
|
+
/** Enable native sync after consent; capability or write failures warn and never block the Runtime. */
|
|
17
|
+
export declare function ensureNativeSync(input: {
|
|
18
|
+
root: string;
|
|
19
|
+
spaceId: string;
|
|
20
|
+
identity: string;
|
|
21
|
+
harnesses: Harness[];
|
|
22
|
+
yes?: boolean;
|
|
23
|
+
executables?: {
|
|
24
|
+
pi?: string;
|
|
25
|
+
codex?: string;
|
|
26
|
+
};
|
|
27
|
+
}, log?: (line: string) => void): Promise<NativeSyncReport>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { installNativeSync } from "./native-install.js";
|
|
3
|
+
import { nativeRuntimeRoot, readNativeSyncConfig } from "./native-sync.js";
|
|
4
|
+
import { serializeDiagnosticError } from "./diagnostics.js";
|
|
5
|
+
/** Already enabled for this exact directory and every requested Harness: installation is idempotent. */
|
|
6
|
+
export function nativeSyncSatisfied(config, input) {
|
|
7
|
+
return Boolean(config && config.spaceId === input.spaceId && config.root === input.root && input.harnesses.every((harness) => config.harnesses.includes(harness)));
|
|
8
|
+
}
|
|
9
|
+
/** One explicit consent prompt, defaulting to yes; native history may contain secrets. */
|
|
10
|
+
export async function askNativeSyncConsent(harnesses, ask = defaultAsk) {
|
|
11
|
+
const answer = await ask(`Sync native ${harnesses.join(" / ")} chats in this folder and upload their history (may contain secrets) to this Space? [Y/n] `);
|
|
12
|
+
return /^(|y(es)?)$/i.test(answer.trim());
|
|
13
|
+
}
|
|
14
|
+
async function defaultAsk(question) {
|
|
15
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
16
|
+
try {
|
|
17
|
+
return await rl.question(question);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
rl.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Enable native sync after consent; capability or write failures warn and never block the Runtime. */
|
|
24
|
+
export async function ensureNativeSync(input, log = (line) => process.stderr.write(line)) {
|
|
25
|
+
const config = await readNativeSyncConfig(nativeRuntimeRoot(input.spaceId), input.identity).catch(() => null);
|
|
26
|
+
if (nativeSyncSatisfied(config, input))
|
|
27
|
+
return { enabled: true, harnesses: config?.harnesses ?? [] };
|
|
28
|
+
if (!input.yes) {
|
|
29
|
+
// Non-interactive sessions cannot consent; they continue without sync instead of failing `up`.
|
|
30
|
+
if (!process.stdin.isTTY) {
|
|
31
|
+
log("Native sync off · rerun interactively or with --yes to enable\n");
|
|
32
|
+
return { enabled: false, harnesses: input.harnesses, note: "non-interactive" };
|
|
33
|
+
}
|
|
34
|
+
if (!await askNativeSyncConsent(input.harnesses))
|
|
35
|
+
return { enabled: false, harnesses: input.harnesses, note: "declined" };
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const result = await installNativeSync(input);
|
|
39
|
+
log(`Native sync enabled. Reload Pi or restart Codex and review its hook trust prompt.\n${result.configPath}`);
|
|
40
|
+
return { enabled: true, harnesses: result.harnesses };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
const message = serializeDiagnosticError(error).message;
|
|
44
|
+
log(`Native sync unavailable; Runtime continues without it — ${message}\n`);
|
|
45
|
+
return { enabled: false, harnesses: input.harnesses, note: message };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -5,7 +5,7 @@ export async function runCodexNativeHook(payload) {
|
|
|
5
5
|
return;
|
|
6
6
|
const value = payload;
|
|
7
7
|
if (!value || typeof value.cwd !== "string" || typeof value.session_id !== "string")
|
|
8
|
-
throw new Error("Invalid Codex hook identity
|
|
8
|
+
throw new Error("Invalid Codex hook identity");
|
|
9
9
|
if (typeof value.transcript_path !== "string" || !value.transcript_path)
|
|
10
10
|
return;
|
|
11
11
|
const result = await requestNativeDaemon({ harness: "codex", cwd: value.cwd, path: value.transcript_path, nativeSessionId: value.session_id });
|
|
@@ -17,12 +17,12 @@ async function main() {
|
|
|
17
17
|
for await (const chunk of process.stdin) {
|
|
18
18
|
input += chunk.toString();
|
|
19
19
|
if (Buffer.byteLength(input) > 4 * 1024 * 1024)
|
|
20
|
-
throw new Error("Codex hook input is too large
|
|
20
|
+
throw new Error("Codex hook input is too large");
|
|
21
21
|
}
|
|
22
22
|
await runCodexNativeHook(JSON.parse(input));
|
|
23
23
|
}
|
|
24
24
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
25
25
|
void main().catch((error) => {
|
|
26
|
-
process.stderr.write(`Cohub sync pending; native execution continues
|
|
26
|
+
process.stderr.write(`Cohub sync pending; native execution continues: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
27
27
|
});
|
|
28
28
|
}
|
|
@@ -21,7 +21,7 @@ async function installText(path, update) {
|
|
|
21
21
|
const info = await lstat(path).catch((error) => { if (missing(error))
|
|
22
22
|
return null; throw error; });
|
|
23
23
|
if (info && (!info.isFile() || info.isSymbolicLink()))
|
|
24
|
-
throw new Error(`Refusing to replace a non-regular file
|
|
24
|
+
throw new Error(`Refusing to replace a non-regular file: ${path}`);
|
|
25
25
|
const original = info ? await readFile(path, "utf8") : null;
|
|
26
26
|
const next = update(original);
|
|
27
27
|
if (next === original)
|
|
@@ -54,7 +54,7 @@ async function installText(path, update) {
|
|
|
54
54
|
const current = await readFile(path, "utf8").catch((error) => { if (missing(error))
|
|
55
55
|
return null; throw error; });
|
|
56
56
|
if (current !== original)
|
|
57
|
-
throw new Error(`Configuration changed during installation
|
|
57
|
+
throw new Error(`Configuration changed during installation: ${path}`);
|
|
58
58
|
await rename(temporary, path);
|
|
59
59
|
const directory = await open(dirname(path), "r");
|
|
60
60
|
try {
|
|
@@ -75,10 +75,10 @@ export async function verifyNativeSyncSupport(harnesses, cwd, executables = {})
|
|
|
75
75
|
if (harness === "pi") {
|
|
76
76
|
const version = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(stdout);
|
|
77
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+
|
|
78
|
+
throw new Error("Native sync requires Pi 0.85.1+");
|
|
79
79
|
}
|
|
80
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
|
|
81
|
+
throw new Error("Install a Codex version with stable Hooks and enable hooks first");
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
/** Install once in the user's native configuration; data collection remains explicitly project-scoped. */
|
|
@@ -92,10 +92,10 @@ export async function installNativeSync(input) {
|
|
|
92
92
|
if (!input.disabled)
|
|
93
93
|
for (const harness of input.harnesses) {
|
|
94
94
|
if (harness === "pi") {
|
|
95
|
-
const content = `// Cohub native Turn sync
|
|
95
|
+
const content = `// Cohub native Turn sync\nexport { default } from ${JSON.stringify(extension.href)};\n`;
|
|
96
96
|
await installText(join(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"), "extensions", "cohub.ts"), (existing) => {
|
|
97
97
|
if (existing !== null && existing !== content)
|
|
98
|
-
throw new Error("Pi Cohub extension already exists; preserve it and review manually
|
|
98
|
+
throw new Error("Pi Cohub extension already exists; preserve it and review manually");
|
|
99
99
|
return content;
|
|
100
100
|
});
|
|
101
101
|
}
|
|
@@ -105,9 +105,9 @@ export async function installNativeSync(input) {
|
|
|
105
105
|
if (existing?.includes(block))
|
|
106
106
|
return existing;
|
|
107
107
|
if (existing?.includes(START) || existing?.includes(END))
|
|
108
|
-
throw new Error("Codex Cohub hook block differs; preserve it and review manually
|
|
108
|
+
throw new Error("Codex Cohub hook block differs; preserve it and review manually");
|
|
109
109
|
if (existing && /^\s*hooks\s*=/m.test(existing))
|
|
110
|
-
throw new Error("Inline Codex hooks require manual merging
|
|
110
|
+
throw new Error("Inline Codex hooks require manual merging");
|
|
111
111
|
return `${existing ?? ""}${existing?.endsWith("\n") ? "\n" : "\n\n"}${block}`;
|
|
112
112
|
});
|
|
113
113
|
}
|
|
@@ -115,7 +115,7 @@ export async function installNativeSync(input) {
|
|
|
115
115
|
await withRuntimeSpaceBindingsLock(async () => {
|
|
116
116
|
const previous = await readNativeSyncConfig(runtimeRoot, input.identity);
|
|
117
117
|
if (previous && previous.root !== input.root)
|
|
118
|
-
throw new Error("Space native sync belongs to another directory
|
|
118
|
+
throw new Error("Space native sync belongs to another directory");
|
|
119
119
|
const harnesses = new Set(previous?.harnesses ?? []);
|
|
120
120
|
for (const harness of input.harnesses) {
|
|
121
121
|
if (input.disabled)
|
|
@@ -9,8 +9,10 @@ const socketPath = (runtimeRoot) => join(runtimeRoot, "native", "daemon.sock");
|
|
|
9
9
|
const parse = (raw) => {
|
|
10
10
|
const value = JSON.parse(raw);
|
|
11
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
|
-
|
|
12
|
+
|| typeof value.cwd !== "string" || typeof value.path !== "string" || typeof value.nativeSessionId !== "string"
|
|
13
|
+
|| value.origin !== undefined && value.origin !== "local_import"
|
|
14
|
+
|| value.sessionStartedAt !== undefined && typeof value.sessionStartedAt !== "string") {
|
|
15
|
+
throw new Error("Invalid native daemon request");
|
|
14
16
|
}
|
|
15
17
|
return value;
|
|
16
18
|
};
|
|
@@ -25,18 +27,18 @@ export async function nativeDaemonSocketFor(cwd) {
|
|
|
25
27
|
export async function requestNativeDaemon(input) {
|
|
26
28
|
const path = await nativeDaemonSocketFor(input.cwd);
|
|
27
29
|
if (!path)
|
|
28
|
-
return { ok: false, message: "Native Runtime is not bound
|
|
30
|
+
return { ok: false, message: "Native Runtime is not bound" };
|
|
29
31
|
return new Promise((resolve, reject) => {
|
|
30
32
|
const socket = createConnection(path);
|
|
31
33
|
let buffer = "";
|
|
32
|
-
const timer = setTimeout(() => { socket.destroy(); reject(new Error("Native Runtime daemon timed out
|
|
34
|
+
const timer = setTimeout(() => { socket.destroy(); reject(new Error("Native Runtime daemon timed out")); }, 15_000);
|
|
33
35
|
const finish = (error, result) => {
|
|
34
36
|
clearTimeout(timer);
|
|
35
37
|
socket.destroy();
|
|
36
38
|
if (error)
|
|
37
39
|
reject(error);
|
|
38
40
|
else
|
|
39
|
-
resolve(result ?? { ok: false, message: "Empty daemon response
|
|
41
|
+
resolve(result ?? { ok: false, message: "Empty daemon response" });
|
|
40
42
|
};
|
|
41
43
|
socket.once("error", (error) => finish(error));
|
|
42
44
|
socket.on("data", (chunk) => {
|
|
@@ -10,7 +10,7 @@ export default function cohubNativeExtension(pi) {
|
|
|
10
10
|
const report = (error) => {
|
|
11
11
|
const message = error instanceof Error ? error.message : String(error);
|
|
12
12
|
if (message !== lastError)
|
|
13
|
-
context?.ui.notify(`Cohub sync pending
|
|
13
|
+
context?.ui.notify(`Cohub sync pending: ${message}`, "warning");
|
|
14
14
|
lastError = message;
|
|
15
15
|
};
|
|
16
16
|
const capture = (ctx, settled = false) => {
|
|
@@ -32,6 +32,8 @@ export type NativeTurnReceipt = {
|
|
|
32
32
|
endBytes: number;
|
|
33
33
|
contentEndBytes?: number;
|
|
34
34
|
result: NativeTurnComplete | null;
|
|
35
|
+
sessionStartedAt?: string;
|
|
36
|
+
origin?: "local_import";
|
|
35
37
|
progress?: NativeTurnProgress;
|
|
36
38
|
};
|
|
37
39
|
export type NativeSyncTransport = ArchiveTransport & {
|
|
@@ -81,7 +83,10 @@ export declare class NativeSyncStore {
|
|
|
81
83
|
private cloudBindingPath;
|
|
82
84
|
binding(): Promise<NativeBinding>;
|
|
83
85
|
private initialize;
|
|
84
|
-
capture(path: string, transcript: NativeTranscript
|
|
86
|
+
capture(path: string, transcript: NativeTranscript, context?: {
|
|
87
|
+
sessionStartedAt?: string;
|
|
88
|
+
origin?: "local_import";
|
|
89
|
+
}): Promise<void>;
|
|
85
90
|
receipts(pendingOnly?: boolean): Promise<NativeTurnReceipt[]>;
|
|
86
91
|
status(): Promise<{
|
|
87
92
|
harness: "codex" | "pi";
|