@neta-art/cohub-cli 8.0.1 → 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 CHANGED
@@ -85,26 +85,21 @@ COHUB_SPACE_ID=<spaceId> cohub spaces prompt "message" --json
85
85
  Connect one local workspace to a Space and select Pi or Codex per turn.
86
86
 
87
87
  To keep using native terminals with ordinary Cohub Chats / Turns:
88
- 使用原生终端,并同步到普通 Cohub Chat / Turn:
89
88
 
90
89
  ```bash
91
90
  cohub runtime up -d --harness pi --harness codex
92
- cohub runtime attach --harness pi --harness codex
93
- # Reload Pi / restart Codex and approve its hook trust prompt.
94
- # 重载 Pi / 重启 Codex,并审核 Hook 信任提示。
95
- cohub runtime detach --harness pi --harness codex # Pause; retain data / 暂停,保留数据
91
+ # Answer the native sync consent (default yes), then reload Pi / restart Codex
92
+ # and approve its hook trust prompt.
93
+ cohub runtime detach --harness pi --harness codex # Pause; retain data
96
94
  ```
97
95
 
98
- `attach` explicitly authorizes this project's opened conversation history and raw native archives.
99
- The Runtime Supervisor is the single local Daemon: Pi / Codex integrations use its private IPC,
96
+ `up` installs native sync by default after one explicit consent (default yes); declining or a
97
+ capability failure keeps the Runtime running without it, and `up` is idempotent. The Runtime
98
+ Supervisor is the single local Daemon: Pi / Codex integrations use its private IPC,
100
99
  while native Turn events use the existing Runtime WebSocket. Configuration is backed up, existing
101
100
  Runtime bindings are reused, and competing continuations fork at complete Turns without blocking native work. Requires Pi 0.85.1+ or Codex with stable
102
101
  Hooks enabled. User-level integrations remain inert outside opted-in directories. See
103
- [boundaries and privacy](../../docs/local-runtime.md#native-clients--原生客户端).
104
-
105
- `attach` 明确授权上传当前项目打开的对话历史与原生归档;复用现有绑定,配置修改前备份。
106
- 冲突时按完整 Turn 分支,不阻塞本地执行。用户级集成在未授权目录中不会采集数据。
107
-
102
+ [boundaries and privacy](../../docs/local-runtime.md#native-clients).
108
103
 
109
104
  ```bash
110
105
  cohub runtime up ./project --harness pi --harness codex
@@ -142,10 +137,6 @@ local process and remote component status. `down` retains all data and requires
142
137
  when executions remain unconfirmed. `logs --level warn --follow` shows sensitive events;
143
138
  foreground startup prints these automatically. Runtime commands never fall back to Home.
144
139
 
145
- 普通使用只需 `cohub runtime up`,首次提示新建,后续优先复用。`-n` 是 `--new` 的简写,
146
- `--name` 单独指定名称。`-d` 后台运行并返回链接、PID 和日志位置;`down` 停止但保留所有数据。
147
- 断网及临时凭证错误自动重试;断连不代表任务已停止,也不会自动重跑工具。
148
-
149
140
  ## Chats and prompts
150
141
 
151
142
  Use `spaces prompt` for immediate sends, delayed sends, one-time schedules, recurring schedules, new Chats, and existing Chats.
@@ -1,14 +1,15 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
- import { createInterface } from "node:readline/promises";
3
2
  import { createClient } from "../client.js";
4
3
  import { json as outJson, jsonRequested } from "../output.js";
5
4
  import { currentIdentityKey } from "../space.js";
6
5
  import { resolveRuntimeTarget, runtimeUp, parseRuntimeHarnesses } from "../runtime/launch.js";
7
6
  import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "../runtime/space-binding.js";
8
7
  import { installNativeSync } from "../runtime/native-install.js";
8
+ import { discoverNativeImportCandidates, nativeRuntimeRoot, readNativeSyncConfig } from "../runtime/native-sync.js";
9
9
  import { listNativeSyncStores } from "../runtime/native-sync-store.js";
10
+ import { requestNativeDaemon } from "../runtime/native-ipc.js";
10
11
  import { requestRuntimeInstance, runtimeInstanceDirectory } from "../runtime/instance.js";
11
- import { atLeastLevel, diagnosticLevels, formatDiagnostic, printRuntimeSummary } from "../runtime/presentation.js";
12
+ import { atLeastLevel, diagnosticLevels, formatDiagnostic, formatNativeSync, printRuntimeSummary } from "../runtime/presentation.js";
12
13
  import { RuntimeSessionStore } from "../runtime/session-store.js";
13
14
  import { readRuntimeDiagnosticEvents, RuntimeDiagnosticReader, runtimeDiagnosticsDirectory, serializeDiagnosticError } from "../runtime/diagnostics.js";
14
15
  export { resolveLocalSpaceName, parseRuntimeHarnesses } from "../runtime/launch.js";
@@ -27,7 +28,7 @@ export function registerRuntime(program) {
27
28
  .option("--harness <name>", "Pi or Codex; repeatable", (value, previous) => [...previous, value], [])
28
29
  .option("--pi <path>", "Pi executable")
29
30
  .option("--codex <path>", "Codex executable")
30
- .option("-y, --yes", "Accept defaults and authorize local execution")
31
+ .option("-y, --yes", "Accept defaults and authorize local execution and native sync")
31
32
  .option("--verbose", "Show diagnostic details")
32
33
  .option("--json", "JSON output")
33
34
  .action(async (dir, options) => {
@@ -38,57 +39,158 @@ export function registerRuntime(program) {
38
39
  reportFailure(cause);
39
40
  }
40
41
  });
41
- for (const action of ["attach", "detach"])
42
- runtime.command(action)
43
- .description(action === "attach" ? "Sync native Pi / Codex Turns" : "Pause native sync; retain all receipts")
44
- .option("-s, --space <id>", "Target Space")
45
- .option("--harness <name>", "Pi or Codex; repeatable", (value, previous) => [...previous, value], [])
46
- .option("--pi <path>", "Pi executable for capability checks")
47
- .option("--codex <path>", "Codex executable for capability checks")
48
- .option("-y, --yes", "Authorize project conversation and native archive uploads")
49
- .option("--json", "JSON output")
50
- .action(async (options) => {
51
- try {
52
- const spaceId = await resolveRuntimeTarget(program, options.space);
53
- const identity = currentIdentityKey();
54
- if (!identity)
55
- throw new Error("Sign in first");
56
- const root = await canonicalRuntimeRoot(process.cwd());
57
- if (action === "attach" && (await getRuntimeSpaceBinding(root, identity))?.spaceId !== spaceId)
58
- throw new Error("Bind this directory with runtime up --space first");
59
- const instance = await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId));
60
- if (action === "attach" && (!instance || instance.root !== root))
61
- throw new Error("Start this directory's Runtime first: cohub runtime up -d");
62
- if (action === "attach" && !instance?.nativeSync)
63
- throw new Error("Restart the Runtime with this CLI before attaching");
64
- const harnesses = parseRuntimeHarnesses(options.harness.length ? options.harness : instance?.harnesses ?? ["pi", "codex"]);
65
- if (action === "attach" && harnesses.some((harness) => !instance?.harnesses.includes(harness)))
66
- throw new Error("Enable these Harnesses with runtime up first");
67
- if (action === "attach" && !options.yes) {
68
- if (!process.stdin.isTTY)
69
- throw new Error("Use --yes to authorize native sync");
70
- const rl = createInterface({ input: process.stdin, output: process.stderr });
71
- try {
72
- const answer = await rl.question(`Install user-level ${harnesses.join(" / ")} integration and upload this project's opened conversations, tool output and raw archives to this Space? History may contain secrets. [y/N] `);
73
- if (!/^y(es)?$/i.test(answer.trim()))
74
- return;
75
- }
76
- finally {
77
- rl.close();
78
- }
79
- }
80
- const result = await installNativeSync({ root, spaceId, identity, harnesses, disabled: action === "detach", executables: { pi: options.pi, codex: options.codex } });
42
+ runtime.command("detach")
43
+ .description("Pause native sync; retain all receipts")
44
+ .option("-s, --space <id>", "Target Space")
45
+ .option("--harness <name>", "Pi or Codex; repeatable", (value, previous) => [...previous, value], [])
46
+ .option("--json", "JSON output")
47
+ .action(async (options) => {
48
+ try {
49
+ const spaceId = await resolveRuntimeTarget(program, options.space);
50
+ const identity = currentIdentityKey();
51
+ if (!identity)
52
+ throw new Error("Sign in first");
53
+ const root = await canonicalRuntimeRoot(process.cwd());
54
+ if ((await getRuntimeSpaceBinding(root, identity))?.spaceId !== spaceId)
55
+ throw new Error("Bind this directory with runtime up first");
56
+ const instance = await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId));
57
+ const harnesses = parseRuntimeHarnesses(options.harness.length ? options.harness : instance?.harnesses ?? ["pi", "codex"]);
58
+ const result = await installNativeSync({ root, spaceId, identity, harnesses, disabled: true });
59
+ if (jsonRequested(options))
60
+ outJson({ ...result, enabled: false });
61
+ else
62
+ process.stdout.write("Native sync paused; all local records retained\n");
63
+ }
64
+ catch (cause) {
65
+ reportFailure(cause);
66
+ }
67
+ });
68
+ runtime.command("import [dir]")
69
+ .description("Import local conversations")
70
+ .option("-s, --space <id>", "Target Space")
71
+ .option("--harness <name>", "Filter by harness; repeatable", (value, previous) => [...previous, value], [])
72
+ .option("--session <id>", "Filter by native session ID")
73
+ .option("--dry-run", "Preview without importing")
74
+ .option("-y, --yes", "Skip confirmation")
75
+ .option("--json", "Output as JSON")
76
+ .action(async (dir, options) => {
77
+ const controller = new AbortController();
78
+ const stop = () => controller.abort();
79
+ process.once("SIGINT", stop);
80
+ process.once("SIGTERM", stop);
81
+ try {
82
+ const identity = currentIdentityKey();
83
+ if (!identity)
84
+ throw new Error("Sign in first");
85
+ const root = await canonicalRuntimeRoot(dir ?? process.cwd());
86
+ const spaceId = await resolveRuntimeTarget(program, options.space);
87
+ const binding = await getRuntimeSpaceBinding(root, identity);
88
+ if (!binding || binding.spaceId !== spaceId)
89
+ throw new Error("Bind this directory with runtime up first");
90
+ const local = await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId));
91
+ if (!local)
92
+ throw new Error("Local Runtime is not running");
93
+ const config = await readNativeSyncConfig(nativeRuntimeRoot(spaceId), identity);
94
+ if (!config || config.root !== root)
95
+ throw new Error("Native sync is not enabled for this directory");
96
+ const harnesses = parseRuntimeHarnesses(options.harness.length ? options.harness : config.harnesses);
97
+ let lastProgress = 0;
98
+ const discovered = await discoverNativeImportCandidates(root, harnesses, {
99
+ signal: controller.signal,
100
+ onProgress: ({ harness, scanned, candidates: found }) => {
101
+ if (jsonRequested(options) || scanned - lastProgress < 25)
102
+ return;
103
+ lastProgress = scanned;
104
+ process.stderr.write(`Scanned ${scanned} ${harness} transcript${scanned === 1 ? "" : "s"}; found ${found}\n`);
105
+ },
106
+ });
107
+ const candidates = discovered.candidates.filter((candidate) => !options.session || candidate.nativeSessionId === options.session);
108
+ const result = {
109
+ spaceId, root, harnesses, candidates, errors: discovered.errors,
110
+ imported: 0, failed: [], dryRun: Boolean(options.dryRun),
111
+ complete: discovered.errors.length === 0,
112
+ };
113
+ if (jsonRequested(options) && options.dryRun) {
114
+ result.complete = result.complete && result.failed.length === 0;
115
+ outJson(result);
116
+ if (!result.complete)
117
+ process.exitCode = 1;
118
+ return;
119
+ }
120
+ if (!candidates.length) {
121
+ if (!result.complete)
122
+ process.exitCode = 1;
81
123
  if (jsonRequested(options))
82
124
  outJson(result);
83
125
  else
84
- process.stdout.write(action === "attach"
85
- ? `Native sync enabled. Reload Pi or restart Codex and review its hook trust prompt.\n${result.configPath}\n`
86
- : "Native sync paused; all local records retained\n");
126
+ process.stdout.write("No existing native conversations found\n");
127
+ return;
128
+ }
129
+ if (options.dryRun) {
130
+ if (jsonRequested(options))
131
+ outJson(result);
132
+ else {
133
+ process.stdout.write(`Found ${candidates.length} native conversation${candidates.length === 1 ? "" : "s"}\n`);
134
+ for (const candidate of candidates)
135
+ process.stdout.write(` ${candidate.harness} ${candidate.nativeSessionId} ${candidate.turnCount} Turn${candidate.turnCount === 1 ? "" : "s"} ${candidate.path}\n`);
136
+ for (const error of discovered.errors)
137
+ process.stdout.write(`Skipped ${error.path}: ${error.message}\n`);
138
+ }
139
+ if (!result.complete)
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ if (!options.yes) {
144
+ if (!process.stdin.isTTY)
145
+ throw new Error("Use --yes in non-interactive mode");
146
+ const { createInterface } = await import("node:readline/promises");
147
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
148
+ try {
149
+ const answer = await rl.question(`Import ${candidates.length} native conversation${candidates.length === 1 ? "" : "s"} to Space ${spaceId}? [Y/n] `);
150
+ if (!/^(|y(es)?)$/i.test(answer.trim())) {
151
+ if (jsonRequested(options))
152
+ outJson({ ...result, cancelled: true });
153
+ else
154
+ process.stdout.write("Import cancelled\n");
155
+ return;
156
+ }
157
+ }
158
+ finally {
159
+ rl.close();
160
+ }
161
+ }
162
+ for (const candidate of candidates) {
163
+ try {
164
+ const response = await requestNativeDaemon({ harness: candidate.harness, cwd: root, path: candidate.path, nativeSessionId: candidate.nativeSessionId,
165
+ sessionStartedAt: candidate.sessionStartedAt, origin: "local_import", settled: true });
166
+ if (!response.ok)
167
+ throw new Error(response.message);
168
+ result.imported += 1;
169
+ }
170
+ catch (error) {
171
+ result.failed.push({ path: candidate.path, message: error instanceof Error ? error.message : String(error) });
172
+ }
87
173
  }
88
- catch (cause) {
89
- reportFailure(cause);
174
+ result.complete = result.complete && result.failed.length === 0;
175
+ if (jsonRequested(options))
176
+ outJson(result);
177
+ else {
178
+ process.stdout.write(`Imported ${result.imported}/${candidates.length} native conversation${candidates.length === 1 ? "" : "s"}\n`);
179
+ for (const error of [...discovered.errors, ...result.failed])
180
+ process.stdout.write(`Skipped ${error.path}: ${error.message}\n`);
181
+ process.stdout.write("Uploads continue in the local Runtime background\n");
90
182
  }
91
- });
183
+ if (!result.complete)
184
+ process.exitCode = 1;
185
+ }
186
+ catch (cause) {
187
+ reportFailure(cause);
188
+ }
189
+ finally {
190
+ process.removeListener("SIGINT", stop);
191
+ process.removeListener("SIGTERM", stop);
192
+ }
193
+ });
92
194
  runtime.command("status").description("Local and server status")
93
195
  .option("-s, --space <id>", "Target Space")
94
196
  .option("--json", "JSON output")
@@ -105,7 +207,9 @@ export function registerRuntime(program) {
105
207
  identity ? listNativeSyncStores(store.root, spaceId, identity) : [],
106
208
  ]);
107
209
  const nativeSessions = await Promise.all(nativeStores.map((native) => native.status()));
108
- const result = { ...remote.value, spaceId, local, remote: remote.value, remoteError: remote.error, diagnosticsPath: runtimeDiagnosticsDirectory(store.root), pendingLocalArchives, failedLocalArchives, nativeSessions };
210
+ // A corrupt native sync config must never take down the whole status report.
211
+ const nativeSync = identity ? await readNativeSyncConfig(store.root, identity).then((config) => ({ config, error: null }), (error) => ({ config: null, error: serializeDiagnosticError(error).message })) : { config: null, error: null };
212
+ const result = { ...remote.value, spaceId, local, remote: remote.value, remoteError: remote.error, diagnosticsPath: runtimeDiagnosticsDirectory(store.root), pendingLocalArchives, failedLocalArchives, nativeSync: nativeSync.config, nativeSyncError: nativeSync.error, nativeSessions };
109
213
  if (jsonRequested(options))
110
214
  outJson(result);
111
215
  else {
@@ -114,8 +218,7 @@ export function registerRuntime(program) {
114
218
  else
115
219
  process.stdout.write(`Local process Not running\nSpace ${spaceId}\nLogs ${result.diagnosticsPath}\n`);
116
220
  process.stdout.write(`Server ${remote.error ? `Unknown — ${remote.error}` : remote.value?.online ? "Harness connected" : "Offline"}\nArchives ${pendingLocalArchives} pending · ${failedLocalArchives} failed\n`);
117
- if (nativeSessions.length)
118
- process.stdout.write(`Native chats ${nativeSessions.length} · ${nativeSessions.reduce((sum, session) => sum + session.pendingTurns, 0)} Turns pending · ${nativeSessions.reduce((sum, session) => sum + session.pendingArchives, 0)} archives pending\n`);
221
+ process.stdout.write(formatNativeSync(nativeSync.config, nativeSessions, nativeSync.error));
119
222
  }
120
223
  }
121
224
  catch (cause) {
@@ -1,4 +1,4 @@
1
- export declare const SANDBOXD_VERSION = "v2.54.1";
1
+ export declare const SANDBOXD_VERSION = "v2.55.0";
2
2
  export declare class SandboxdDownloadError extends Error {
3
3
  name: string;
4
4
  }
@@ -25,9 +25,10 @@ import { Readable } from "node:stream";
25
25
  // v2.54.1 keeps the same wire protocol and adds relay diagnostics: data-channel
26
26
  // pairing outlives the runner's dial timeout, dial failures distinguish a
27
27
  // timeout from an explicit rejection, and teardown-time websocket write
28
- // failures log at debug instead of warn. No new capability is required, so this
29
- // pin is a diagnostics upgrade only.
30
- export const SANDBOXD_VERSION = "v2.54.1";
28
+ // failures log at debug instead of warn. v2.55.0 republishes that compatible
29
+ // runner alongside the matching platform release; no new runner capability is
30
+ // required.
31
+ export const SANDBOXD_VERSION = "v2.55.0";
31
32
  const BINARY_NAME = "cohub-sandboxd";
32
33
  // Public CDN prefix hosting the release archives (the repo is private, so the
33
34
  // GitHub Release assets are not publicly downloadable). Overridable for staging
@@ -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) {
@@ -10,6 +10,7 @@ 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";
@@ -90,6 +91,9 @@ export async function runtimeUp(program, dir, options) {
90
91
  if (existing.root !== root || options.harness.length && [...existing.harnesses].sort().join() !== [...harnesses].sort().join() || options.pi || options.codex) {
91
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
  }
@@ -122,8 +126,8 @@ export async function runtimeUp(program, dir, options) {
122
126
  if (!requested && (!binding || createNew))
123
127
  name = (await rl.question(`Space name [${name}]: `)).trim() || name;
124
128
  process.stderr.write(`\nDirectory ${root}\n${requested ? `Space ${runtimeWebUrl(requested)}\n` : ""}`);
125
- const answer = await rl.question("Collaborators can execute as your OS user, beyond this folder. Allow? [y/N] ");
126
- if (!/^y(es)?$/i.test(answer.trim()))
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 {
@@ -148,6 +152,8 @@ export async function runtimeUp(program, dir, options) {
148
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) {
@@ -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
+ }
@@ -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,7 +9,9 @@ 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") {
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") {
13
15
  throw new Error("Invalid native daemon request");
14
16
  }
15
17
  return value;
@@ -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";
@@ -112,7 +112,7 @@ export class NativeSyncStore {
112
112
  await atomicRuntimeJson(this.bindingPath(), binding);
113
113
  return binding;
114
114
  }
115
- async capture(path, transcript) {
115
+ async capture(path, transcript, context = {}) {
116
116
  if (transcript.nativeSessionId !== this.options.nativeSessionId)
117
117
  throw new Error("Native session identity mismatch");
118
118
  await withRuntimeSpaceBindingsLock(async () => {
@@ -156,6 +156,8 @@ export class NativeSyncStore {
156
156
  const result = turn.result ? nativeTurnCompleteSchema.parse(turn.result) : null;
157
157
  const receipt = { version: 1, turnId, key: turn.key, parentKey, parentCloudTurnId: parentKey ? null : parentCloudTurnId,
158
158
  userContent: turn.userContent, startedAt: turn.startedAt, endBytes: turn.endBytes, contentEndBytes: turn.contentEndBytes, result,
159
+ ...(context.sessionStartedAt ? { sessionStartedAt: context.sessionStartedAt } : {}),
160
+ ...(context.origin ? { origin: context.origin } : {}),
159
161
  ...(!result ? { progress: nativeTurnProgressSchema.parse({ revision: turn.endBytes, messages: turn.messages }) } : {}) };
160
162
  if (old && (JSON.stringify(old.userContent) !== JSON.stringify(receipt.userContent) || old.parentKey !== receipt.parentKey))
161
163
  throw new Error("Native Turn changed; original receipt retained");
@@ -238,7 +240,8 @@ export class NativeSyncStore {
238
240
  let request = await readJson(this.requestPath(receipt.turnId));
239
241
  if (!request) {
240
242
  request = nativeTurnStartSchema.parse({ turnId: receipt.turnId, sessionId: parent?.sessionId ?? binding.sessionId, parentTurnId: parent?.turnId ?? receipt.parentCloudTurnId,
241
- branchSessionId: nativeStableId(`${receipt.turnId}:branch`), harness: binding.harness, nativeSessionId: binding.nativeSessionId, userContent: receipt.userContent, startedAt: receipt.startedAt });
243
+ branchSessionId: nativeStableId(`${receipt.turnId}:branch`), harness: binding.harness, nativeSessionId: binding.nativeSessionId, userContent: receipt.userContent, startedAt: receipt.startedAt,
244
+ ...(receipt.sessionStartedAt ? { sessionStartedAt: receipt.sessionStartedAt } : {}), ...(receipt.origin ? { origin: receipt.origin } : {}) });
242
245
  await atomicRuntimeJson(this.requestPath(receipt.turnId), request);
243
246
  }
244
247
  let remote = await readJson(this.cloudBindingPath(receipt.turnId));
@@ -9,6 +9,29 @@ export type NativeSyncConfig = {
9
9
  };
10
10
  export declare const nativeRuntimeRoot: (spaceId: string) => string;
11
11
  export declare const nativeSyncConfigPath: (runtimeRoot: string, identity: string) => string;
12
+ export type NativeImportCandidate = {
13
+ harness: "pi" | "codex";
14
+ path: string;
15
+ nativeSessionId: string;
16
+ turnCount: number;
17
+ sessionStartedAt: string | undefined;
18
+ };
19
+ /** Find existing native transcripts for exactly one bound project. */
20
+ export declare function discoverNativeImportCandidates(root: string, harnesses: ("pi" | "codex")[], options?: {
21
+ signal?: AbortSignal;
22
+ onProgress?: (progress: {
23
+ harness: "pi" | "codex";
24
+ scanned: number;
25
+ candidates: number;
26
+ }) => void;
27
+ }): Promise<{
28
+ candidates: NativeImportCandidate[];
29
+ errors: {
30
+ harness: "pi" | "codex";
31
+ path: string;
32
+ message: string;
33
+ }[];
34
+ }>;
12
35
  export declare function nativeArchiveTransport(spaceId: string, identity: string): Pick<NativeSyncTransport, "prepareRuntimeArchive" | "commitRuntimeArchive" | "getRuntimeArchive">;
13
36
  export declare function readNativeSyncConfig(runtimeRoot: string, identity: string): Promise<NativeSyncConfig | null>;
14
37
  /** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
@@ -19,6 +42,8 @@ export declare function captureNativeSession(input: {
19
42
  nativeSessionId?: string;
20
43
  settled?: boolean;
21
44
  leafId?: string | null;
45
+ sessionStartedAt?: string;
46
+ origin?: "local_import";
22
47
  }): Promise<NativeSyncStore | null>;
23
48
  /** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
24
49
  export declare function nativeWebSocketTransport(spaceId: string, identity: string, send: (event: NativeRuntimeEvent) => Promise<unknown>): NativeSyncTransport;
@@ -1,14 +1,95 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { createClient } from "../client.js";
5
5
  import { currentIdentityKey } from "../space.js";
6
6
  import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
7
- import { readNativeTranscript } from "./native-transcript.js";
7
+ import { readNativeTranscript, readNativeTranscriptHeader } from "./native-transcript.js";
8
8
  import { findRuntimeNativeSession } from "./session-store.js";
9
9
  import { listNativeSyncStores, nativeIdentityHash, NativeSyncStore } from "./native-sync-store.js";
10
10
  export const nativeRuntimeRoot = (spaceId) => join(homedir(), ".local", "state", "cohub", "runtime", spaceId);
11
11
  export const nativeSyncConfigPath = (runtimeRoot, identity) => join(runtimeRoot, "native", nativeIdentityHash(identity), "config.json");
12
+ const piSessionDirectory = (cwd) => {
13
+ const custom = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
14
+ if (custom)
15
+ return custom;
16
+ const agentDir = process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
17
+ const safePath = `--${cwd.replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`;
18
+ return join(agentDir, "sessions", safePath);
19
+ };
20
+ const codexSessionDirectory = () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions");
21
+ const LARGE_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
22
+ async function nativeTranscriptPaths(root, harness, signal) {
23
+ const pending = [harness === "pi" ? piSessionDirectory(root) : codexSessionDirectory()];
24
+ const paths = [];
25
+ const errors = [];
26
+ while (pending.length) {
27
+ signal?.throwIfAborted();
28
+ const directory = pending.pop();
29
+ if (!directory)
30
+ continue;
31
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
32
+ if (error.code === "ENOENT")
33
+ return [];
34
+ errors.push({ harness, path: directory, message: error instanceof Error ? error.message : String(error) });
35
+ return [];
36
+ });
37
+ for (const entry of entries) {
38
+ const path = join(directory, entry.name);
39
+ if (entry.isDirectory())
40
+ pending.push(path);
41
+ else if (entry.isFile() && entry.name.endsWith(".jsonl"))
42
+ paths.push(path);
43
+ }
44
+ }
45
+ return { paths, errors };
46
+ }
47
+ /** Find existing native transcripts for exactly one bound project. */
48
+ export async function discoverNativeImportCandidates(root, harnesses, options = {}) {
49
+ const candidates = [];
50
+ const errors = [];
51
+ for (const harness of harnesses) {
52
+ options.signal?.throwIfAborted();
53
+ const scanned = await nativeTranscriptPaths(root, harness, options.signal);
54
+ errors.push(...scanned.errors);
55
+ let start = 0;
56
+ while (start < scanned.paths.length) {
57
+ options.signal?.throwIfAborted();
58
+ const paths = scanned.paths.slice(start, start + 2);
59
+ const sizes = await Promise.all(paths.map((path) => stat(path).then((value) => value.size).catch(() => 0)));
60
+ const concurrency = sizes.some((size) => size > LARGE_TRANSCRIPT_BYTES) ? 1 : 2;
61
+ const batch = await Promise.all(paths.slice(0, concurrency).map(async (path) => {
62
+ try {
63
+ const header = await readNativeTranscriptHeader(path, harness, options.signal);
64
+ if (await canonicalRuntimeRoot(header.cwd) !== root)
65
+ return null;
66
+ const transcript = await readNativeTranscript(path, harness, { settled: true, signal: options.signal });
67
+ if (transcript.turns.length === 0)
68
+ return { error: { harness, path, message: "Transcript contains no importable Turns" } };
69
+ return { harness, path, nativeSessionId: transcript.nativeSessionId, turnCount: transcript.turns.length,
70
+ sessionStartedAt: transcript.turns.map((turn) => turn.startedAt).sort()[0] };
71
+ }
72
+ catch (error) {
73
+ return { error: { harness, path, message: error instanceof Error ? error.message : String(error) } };
74
+ }
75
+ }));
76
+ for (const result of batch) {
77
+ options.signal?.throwIfAborted();
78
+ if (!result)
79
+ continue;
80
+ if ("error" in result) {
81
+ if (result.error)
82
+ errors.push(result.error);
83
+ }
84
+ else
85
+ candidates.push(result);
86
+ options.onProgress?.({ harness, scanned: start + batch.length, candidates: candidates.length });
87
+ }
88
+ start += concurrency;
89
+ }
90
+ }
91
+ return { candidates, errors };
92
+ }
12
93
  export function nativeArchiveTransport(spaceId, identity) {
13
94
  const client = createClient().space(spaceId);
14
95
  const guard = async (task) => {
@@ -83,7 +164,7 @@ export async function captureNativeSession(input) {
83
164
  nativeStores.delete(nativeStores.keys().next().value ?? "");
84
165
  nativeStores.set(key, store);
85
166
  }
86
- await store.capture(path, transcript);
167
+ await store.capture(path, transcript, { sessionStartedAt: input.sessionStartedAt, origin: input.origin });
87
168
  return store;
88
169
  }
89
170
  /** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
@@ -20,8 +20,16 @@ export type NativeTranscript = {
20
20
  turns: NativeTranscriptTurn[];
21
21
  prefixes: ReadonlyMap<number, string>;
22
22
  };
23
+ export declare const NATIVE_MAX_RECORD_BYTES: number;
24
+ export declare const NATIVE_MAX_TRANSCRIPT_BYTES: number;
25
+ /** Read only the session header before deciding whether a global Codex file belongs to this project. */
26
+ export declare function readNativeTranscriptHeader(path: string, harness: "pi" | "codex", signal?: AbortSignal): Promise<{
27
+ nativeSessionId: string;
28
+ cwd: string;
29
+ }>;
23
30
  /** Partial trailing records are retried, never parsed or acknowledged as complete. */
24
31
  export declare function readNativeTranscript(path: string, harness: "pi" | "codex", options?: {
25
32
  settled?: boolean;
26
33
  leafId?: string | null;
34
+ signal?: AbortSignal;
27
35
  }): Promise<NativeTranscript>;
@@ -11,6 +11,35 @@ const iso = (value) => {
11
11
  throw new Error("Invalid native timestamp");
12
12
  return date.toISOString();
13
13
  };
14
+ export const NATIVE_MAX_RECORD_BYTES = 256 * 1024 * 1024;
15
+ export const NATIVE_MAX_TRANSCRIPT_BYTES = 1024 * 1024 * 1024;
16
+ const NATIVE_HEADER_MAX_BYTES = 1024 * 1024;
17
+ /** Read only the session header before deciding whether a global Codex file belongs to this project. */
18
+ export async function readNativeTranscriptHeader(path, harness, signal) {
19
+ const stream = createReadStream(path, { signal });
20
+ let buffer = Buffer.alloc(0);
21
+ try {
22
+ for await (const chunk of stream) {
23
+ buffer = Buffer.concat([buffer, chunk]);
24
+ const newline = buffer.indexOf(10);
25
+ if (newline >= 0) {
26
+ const value = record(JSON.parse(buffer.subarray(0, newline).toString("utf8")));
27
+ const payload = record(value.payload);
28
+ const nativeSessionId = harness === "pi" ? text(value.id) : text(payload.id);
29
+ const cwd = harness === "pi" ? text(value.cwd) : text(payload.cwd);
30
+ if ((harness === "pi" && value.type !== "session") || (harness === "codex" && value.type !== "session_meta") || !nativeSessionId || !cwd)
31
+ throw new Error("Invalid native session header");
32
+ return { nativeSessionId, cwd };
33
+ }
34
+ if (buffer.length > NATIVE_HEADER_MAX_BYTES)
35
+ throw new Error("Native session header is too large");
36
+ }
37
+ throw new Error("Native transcript is empty");
38
+ }
39
+ finally {
40
+ stream.destroy();
41
+ }
42
+ }
14
43
  /** Partial trailing records are retried, never parsed or acknowledged as complete. */
15
44
  export async function readNativeTranscript(path, harness, options = {}) {
16
45
  const lines = [];
@@ -23,12 +52,12 @@ export async function readNativeTranscript(path, harness, options = {}) {
23
52
  fragments.push(bytes);
24
53
  pendingBytes += bytes.length;
25
54
  checksum.update(bytes);
26
- if (pendingBytes > 32 * 1024 * 1024)
27
- throw new Error("Native record is too large");
28
- if (offset + pendingBytes > 128 * 1024 * 1024)
29
- throw new Error("Native transcript exceeds the capture limit; original retained");
55
+ if (pendingBytes > NATIVE_MAX_RECORD_BYTES)
56
+ throw new Error("Native record exceeds the 256 MiB local parse limit; original retained");
57
+ if (offset + pendingBytes > NATIVE_MAX_TRANSCRIPT_BYTES)
58
+ throw new Error("Native transcript exceeds the 1 GiB local parse limit; original retained");
30
59
  };
31
- for await (const chunk of createReadStream(path)) {
60
+ for await (const chunk of createReadStream(path, { signal: options.signal })) {
32
61
  let start = 0;
33
62
  for (let end = chunk.indexOf(10); end >= 0; end = chunk.indexOf(10, start)) {
34
63
  append(chunk.subarray(start, end + 1));
@@ -1,3 +1,4 @@
1
+ import type { NativeSyncConfig } from "./native-sync.js";
1
2
  import type { RuntimeDiagnostic, RuntimeDiagnosticLevel } from "./diagnostics.js";
2
3
  export declare const diagnosticLevels: RuntimeDiagnosticLevel[];
3
4
  export declare const atLeastLevel: (level: RuntimeDiagnosticLevel, minimum: RuntimeDiagnosticLevel) => boolean;
@@ -16,6 +17,14 @@ export type RuntimeSummary = {
16
17
  workspaceConnected: boolean;
17
18
  diagnosticsPath: string;
18
19
  background: boolean;
19
- nativeSync?: boolean;
20
20
  };
21
21
  export declare function printRuntimeSummary(summary: RuntimeSummary, json?: boolean, reused?: boolean): void;
22
+ export type NativeSessionStatus = {
23
+ harness: "pi" | "codex";
24
+ nativeSessionId: string;
25
+ sessionId: string | null;
26
+ pendingTurns: number;
27
+ pendingArchives: number;
28
+ };
29
+ /** `runtime status` native sync block: installation state plus per-Harness pending work. */
30
+ export declare function formatNativeSync(config: NativeSyncConfig | null, sessions: NativeSessionStatus[], error?: string | null): string;
@@ -76,3 +76,19 @@ export function printRuntimeSummary(summary, json = false, reused = false) {
76
76
  process.stdout.write(" Ctrl+C to stop\n");
77
77
  process.stdout.write("\n");
78
78
  }
79
+ /** `runtime status` native sync block: installation state plus per-Harness pending work. */
80
+ export function formatNativeSync(config, sessions, error = null) {
81
+ if (error)
82
+ return `Native sync unknown — ${error} · fix or remove the config, then runtime up\n`;
83
+ if (!config?.harnesses.length)
84
+ return "Native sync off · run cohub runtime up to enable\n";
85
+ const lines = [`Native sync enabled · ${config.harnesses.join(", ")}`];
86
+ for (const session of sessions) {
87
+ lines.push(` ${session.harness.padEnd(5)} ${session.nativeSessionId.slice(0, 8)} ${session.pendingTurns} Turns · ${session.pendingArchives} archives pending`);
88
+ }
89
+ if (!sessions.length)
90
+ lines.push(" No native chats captured yet");
91
+ else if (sessions.every((session) => !session.pendingTurns && !session.pendingArchives))
92
+ lines.push(" Up to date");
93
+ return `${lines.join("\n")}\n`;
94
+ }
@@ -41,7 +41,6 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
41
41
  pid: process.pid, harnesses: config.harnesses, background: config.background,
42
42
  state: "starting", harnessConnected: false, workspaceConnected: false,
43
43
  diagnosticsPath: diagnostics.directory,
44
- nativeSync: true,
45
44
  };
46
45
  let hasBeenReady = false;
47
46
  const update = (patch) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "8.0.1",
3
+ "version": "8.1.0",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -22,7 +22,7 @@
22
22
  "commander": "^15.0.0",
23
23
  "pixi.js": "^8.20.1",
24
24
  "sharp": "^0.35.4",
25
- "@neta-art/cohub": "8.21.0"
25
+ "@neta-art/cohub": "8.21.1"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"