@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.
Files changed (36) hide show
  1. package/README.md +7 -16
  2. package/dist/commands/runtime.js +190 -87
  3. package/dist/commands/sandboxd-binary.d.ts +1 -1
  4. package/dist/commands/sandboxd-binary.js +8 -1
  5. package/dist/runtime/archive-store.d.ts +3 -1
  6. package/dist/runtime/archive-store.js +2 -0
  7. package/dist/runtime/connection.js +34 -22
  8. package/dist/runtime/diagnostics.js +8 -1
  9. package/dist/runtime/harness.d.ts +4 -0
  10. package/dist/runtime/harness.js +29 -16
  11. package/dist/runtime/instance.js +5 -5
  12. package/dist/runtime/json-rpc.d.ts +2 -0
  13. package/dist/runtime/json-rpc.js +2 -0
  14. package/dist/runtime/launch.js +29 -23
  15. package/dist/runtime/native-attach.d.ts +28 -0
  16. package/dist/runtime/native-attach.js +47 -0
  17. package/dist/runtime/native-codex-hook.js +3 -3
  18. package/dist/runtime/native-install.js +9 -9
  19. package/dist/runtime/native-ipc.d.ts +2 -0
  20. package/dist/runtime/native-ipc.js +7 -5
  21. package/dist/runtime/native-pi-extension.js +1 -1
  22. package/dist/runtime/native-sync-store.d.ts +6 -1
  23. package/dist/runtime/native-sync-store.js +24 -21
  24. package/dist/runtime/native-sync.d.ts +25 -0
  25. package/dist/runtime/native-sync.js +89 -8
  26. package/dist/runtime/native-transcript.d.ts +8 -0
  27. package/dist/runtime/native-transcript.js +45 -16
  28. package/dist/runtime/presentation.d.ts +10 -1
  29. package/dist/runtime/presentation.js +47 -29
  30. package/dist/runtime/process-group.d.ts +2 -0
  31. package/dist/runtime/process-group.js +124 -31
  32. package/dist/runtime/session-store.d.ts +15 -2
  33. package/dist/runtime/session-store.js +79 -5
  34. package/dist/runtime/space-binding.js +3 -3
  35. package/dist/runtime/supervisor.js +4 -5
  36. 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
- .replace(/\b(rediss?|https?|wss?):\/\/[^\s]+/gi, (url) => redactUrl(url))
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;
@@ -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.close();
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
- component: "harness",
244
- sessionId: input.sessionId,
245
- turnId: input.turnId,
246
- harness: "pi",
247
- traceContext: input.traceContext,
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.close();
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
- component: "harness",
462
- sessionId: input.sessionId,
463
- turnId: input.turnId,
464
- harness: "codex",
465
- traceContext: input.traceContext,
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
  }
@@ -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 / Runtime 实例记录无效");
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 / Runtime 控制连接超时")); }, 3000);
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 / Runtime 控制连接已关闭")));
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 / Runtime 身份已变化"));
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 / Runtime 已在运行,请使用 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;
@@ -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();
@@ -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 / Harness 必须是 pi 或 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 / 此目录未绑定 Space,请使用 --space <id> 或 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 / Runtime 未启动"));
51
+ reject(new Error("Runtime did not start"));
51
52
  };
52
- const timeout = setTimeout(() => finish(last ? undefined : new Error("Runtime startup timed out / Runtime 启动超时")), 30_000);
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}) / Runtime 已退出`)));
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 / --new 不能与显式 Space 同时使用");
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 / --name 仅用于新建 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 / Runtime 正使用不同配置运行,请先 down");
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 / 请安装并登录 Pi 或 Codex,或显式指定 --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 / 请使用 --yes 授权本地执行");
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 / 已关联 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();
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] / 创建新 Space?[Y/n] ")).trim().toLowerCase();
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 / 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
+ 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 / 请先停止此目录的 Runtime,再创建新绑定");
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 / 此 Space 不是本地 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 / 此 Space 已在另一目录运行");
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 / 正在连接\n ${runtimeWebUrl(spaceId)}\n Logs / 日志 ${status.diagnosticsPath}\n`);
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 / Codex Hook 身份无效");
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 / Codex Hook 输入过大");
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 / Cohub 待同步,原生执行继续: ${error instanceof Error ? error.message : String(error)}\n`);
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 / 不覆盖非普通文件: ${path}`);
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 / 安装期间配置已变化: ${path}`);
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+ / 原生同步需要 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 / 请安装支持稳定 Hooks 的 Codex 版本并启用 Hooks");
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 / Cohub 原生 Turn 同步\nexport { default } from ${JSON.stringify(extension.href)};\n`;
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 / Pi Cohub 扩展已存在,请保留并手动核对");
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 / Codex Cohub Hook 配置不同,请保留并手动核对");
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 / 内联 Codex Hooks 需要手动合并");
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 / 此 Space 原生同步属于其他目录");
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)
@@ -7,6 +7,8 @@ type NativeIpcRequest = {
7
7
  nativeSessionId: string;
8
8
  settled?: boolean;
9
9
  leafId?: string | null;
10
+ sessionStartedAt?: string;
11
+ origin?: "local_import";
10
12
  };
11
13
  type NativeIpcResponse = {
12
14
  ok: true;
@@ -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
- throw new Error("Invalid native daemon request / 原生 Daemon 请求无效");
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 / 原生 Runtime 未绑定" };
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 / 原生 Runtime Daemon 超时")); }, 15_000);
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 / Daemon 返回为空" });
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 / Cohub 待同步: ${message}`, "warning");
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): Promise<void>;
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";