@neta-art/cohub-cli 7.1.2 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +40 -2
  2. package/dist/auth.js +38 -5
  3. package/dist/client.js +5 -2
  4. package/dist/commands/runtime.d.ts +1 -2
  5. package/dist/commands/runtime.js +178 -302
  6. package/dist/commands/sandboxd-binary.d.ts +1 -1
  7. package/dist/commands/sandboxd-binary.js +7 -5
  8. package/dist/runtime/archive-store.d.ts +2 -0
  9. package/dist/runtime/archive-store.js +20 -6
  10. package/dist/runtime/connection.d.ts +4 -2
  11. package/dist/runtime/connection.js +80 -23
  12. package/dist/runtime/diagnostics.d.ts +3 -0
  13. package/dist/runtime/diagnostics.js +3 -0
  14. package/dist/runtime/harness.d.ts +3 -0
  15. package/dist/runtime/harness.js +34 -1
  16. package/dist/runtime/instance.d.ts +5 -0
  17. package/dist/runtime/instance.js +159 -0
  18. package/dist/runtime/launch.d.ts +20 -0
  19. package/dist/runtime/launch.js +176 -0
  20. package/dist/runtime/native-codex-hook.d.ts +1 -0
  21. package/dist/runtime/native-codex-hook.js +28 -0
  22. package/dist/runtime/native-install.d.ts +21 -0
  23. package/dist/runtime/native-install.js +130 -0
  24. package/dist/runtime/native-ipc.d.ts +26 -0
  25. package/dist/runtime/native-ipc.js +101 -0
  26. package/dist/runtime/native-pi-extension.d.ts +20 -0
  27. package/dist/runtime/native-pi-extension.js +47 -0
  28. package/dist/runtime/native-sync-store.d.ts +97 -0
  29. package/dist/runtime/native-sync-store.js +365 -0
  30. package/dist/runtime/native-sync.d.ts +25 -0
  31. package/dist/runtime/native-sync.js +128 -0
  32. package/dist/runtime/native-transcript.d.ts +27 -0
  33. package/dist/runtime/native-transcript.js +281 -0
  34. package/dist/runtime/presentation.d.ts +21 -0
  35. package/dist/runtime/presentation.js +76 -0
  36. package/dist/runtime/session-store.d.ts +2 -0
  37. package/dist/runtime/session-store.js +40 -5
  38. package/dist/runtime/space-binding.d.ts +3 -0
  39. package/dist/runtime/space-binding.js +43 -6
  40. package/dist/runtime/supervisor.d.ts +16 -0
  41. package/dist/runtime/supervisor.js +277 -0
  42. package/dist/runtime/worker.d.ts +1 -0
  43. package/dist/runtime/worker.js +20 -0
  44. package/package.json +3 -2
@@ -1,328 +1,204 @@
1
- import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { stat } from "node:fs/promises";
4
- import { basename, resolve } from "node:path";
1
+ import { setTimeout as delay } from "node:timers/promises";
5
2
  import { createInterface } from "node:readline/promises";
6
- import { isLocalHarness, resolveCohubEnvironment, resolveWebsocketUrl } from "@neta-art/cohub";
7
- import { requireAccessToken } from "../auth.js";
8
3
  import { createClient } from "../client.js";
9
- import { error, json as outJson, jsonRequested } from "../output.js";
10
- import { currentIdentityKey, explicitSpace, resolveSpace } from "../space.js";
11
- import { canonicalRuntimeRoot, resolveRuntimeSpace } from "../runtime/space-binding.js";
12
- import { discoverHarnesses } from "../runtime/harness.js";
13
- import { serveRuntime } from "../runtime/connection.js";
4
+ import { json as outJson, jsonRequested } from "../output.js";
5
+ import { currentIdentityKey } from "../space.js";
6
+ import { resolveRuntimeTarget, runtimeUp, parseRuntimeHarnesses } from "../runtime/launch.js";
7
+ import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "../runtime/space-binding.js";
8
+ import { installNativeSync } from "../runtime/native-install.js";
9
+ import { listNativeSyncStores } from "../runtime/native-sync-store.js";
10
+ import { requestRuntimeInstance, runtimeInstanceDirectory } from "../runtime/instance.js";
11
+ import { atLeastLevel, diagnosticLevels, formatDiagnostic, printRuntimeSummary } from "../runtime/presentation.js";
14
12
  import { RuntimeSessionStore } from "../runtime/session-store.js";
15
- import { ensureSandboxdBinary } from "./sandboxd-binary.js";
16
- import { readRuntimeDiagnosticEvents, RuntimeDiagnosticReader, RuntimeDiagnostics, runtimeDiagnosticsDirectory, serializeDiagnosticError, } from "../runtime/diagnostics.js";
17
- export const resolveLocalSpaceName = (root, name) => name?.trim() || basename(root) || "local-space";
18
- function sandboxOutputLevel(value, stream) {
19
- const level = typeof value === "string" ? value.toLowerCase() : "";
20
- if (level.includes("error"))
21
- return "error";
22
- if (level.includes("warn"))
23
- return "warn";
24
- return stream === "stderr" ? "error" : "debug";
25
- }
26
- function captureSandboxOutput(stream, streamName, diagnostics) {
27
- if (!stream)
28
- return;
29
- let pending = "";
30
- const consume = (chunk) => {
31
- pending += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
32
- let newline = pending.indexOf("\n");
33
- while (newline >= 0) {
34
- const line = pending.slice(0, newline).trim();
35
- pending = pending.slice(newline + 1);
36
- if (line)
37
- recordSandboxOutput(line, streamName, diagnostics);
38
- newline = pending.indexOf("\n");
39
- }
40
- };
41
- stream.on("data", consume);
42
- stream.on("end", () => {
43
- if (pending.trim())
44
- recordSandboxOutput(pending.trim(), streamName, diagnostics);
45
- });
46
- }
47
- function recordSandboxOutput(line, streamName, diagnostics) {
48
- let parsed = null;
49
- try {
50
- const value = JSON.parse(line);
51
- if (value && typeof value === "object" && !Array.isArray(value))
52
- parsed = value;
53
- }
54
- catch {
55
- // Older or third-party binaries may still emit text logs.
56
- }
57
- const level = sandboxOutputLevel(parsed?.level, streamName);
58
- const message = typeof parsed?.msg === "string" ? parsed.msg : line;
59
- const data = parsed
60
- ? Object.fromEntries(Object.entries(parsed).filter(([key]) => !["msg", "level", "time"].includes(key)))
61
- : { message: line };
62
- diagnostics.log(level, "sandboxd.log", {
63
- stream: streamName,
64
- message,
65
- ...(level === "error" ? { error: { message } } : {}),
66
- ...data,
67
- }, { component: "sandboxd" });
68
- }
69
- function printDiagnostic(event) {
70
- const scope = [event.component, event.event].filter(Boolean).join(".");
71
- const context = [
72
- event.connectionId && `connection=${event.connectionId}`,
73
- event.sessionId && `session=${event.sessionId}`,
74
- event.turnId && `turn=${event.turnId}`,
75
- event.traceContext?.requestId && `request=${event.traceContext.requestId}`,
76
- event.traceContext?.traceId && `trace=${event.traceContext.traceId}`,
77
- ].filter(Boolean).join(" ");
78
- const data = event.data && Object.keys(event.data).length > 0 ? ` ${JSON.stringify(event.data)}` : "";
79
- process.stdout.write(`${event.timestamp} ${event.level.toUpperCase().padEnd(5)} ${scope}${context ? ` ${context}` : ""}${data}${event.error ? ` ${JSON.stringify(event.error)}` : ""}\n`);
80
- }
81
- export function parseRuntimeHarnesses(values) {
82
- const names = values.flatMap((value) => value.split(",")).map((name) => name.trim()).filter(Boolean);
83
- if (names.some((name) => !isLocalHarness(name)))
84
- throw new Error("Harness must be pi or codex");
85
- return [...new Set(names.length ? names : ["pi"])];
86
- }
13
+ import { readRuntimeDiagnosticEvents, RuntimeDiagnosticReader, runtimeDiagnosticsDirectory, serializeDiagnosticError } from "../runtime/diagnostics.js";
14
+ export { resolveLocalSpaceName, parseRuntimeHarnesses } from "../runtime/launch.js";
15
+ const reportFailure = (cause) => {
16
+ process.stderr.write(`Runtime failed / Runtime 失败: ${serializeDiagnosticError(cause).message}\n`);
17
+ process.exitCode = 1;
18
+ };
87
19
  export function registerRuntime(program) {
88
- const runtime = program.command("runtime").description("Connect a local workspace");
20
+ const runtime = program.command("runtime").description("Connect a local workspace / 连接本地工作区");
89
21
  runtime.command("up [dir]")
90
- .description("Connect local Harnesses and files")
91
- .option("-s, --space <id>", "Target Space")
92
- .option("-n, --name <name>", "New Space name")
93
- .option("--harness <name>", "Pi or Codex; repeatable", (value, previous) => [...previous, value], [])
94
- .option("--pi <path>", "Pi executable")
95
- .option("--codex <path>", "Codex executable")
96
- .option("-y, --yes", "Accept local execution access")
97
- .option("--json", "JSON output")
22
+ .description("Connect local Harnesses and files / 连接本地 Harness 和文件")
23
+ .option("-s, --space <id>", "Target Space / 目标 Space")
24
+ .option("-n, --new", "Create a new Space / 创建新 Space")
25
+ .option("--name <name>", "New Space name / Space 名称")
26
+ .option("-d, --detach", "Run in the background / 后台运行")
27
+ .option("--harness <name>", "Pi or Codex; repeatable / 可重复指定", (value, previous) => [...previous, value], [])
28
+ .option("--pi <path>", "Pi executable / Pi 可执行文件")
29
+ .option("--codex <path>", "Codex executable / Codex 可执行文件")
30
+ .option("-y, --yes", "Accept defaults and local execution / 接受默认选择并授权本地执行")
31
+ .option("--verbose", "Show diagnostic details / 显示诊断详情")
32
+ .option("--json", "JSON output / JSON 输出")
98
33
  .action(async (dir, options) => {
99
- const controller = new AbortController();
100
- const stop = () => controller.abort();
101
- process.once("SIGINT", stop);
102
- process.once("SIGTERM", stop);
103
34
  try {
104
- const requestedRoot = resolve(dir ?? process.cwd());
105
- if (!(await stat(requestedRoot)).isDirectory())
106
- throw new Error("Workspace is not a directory");
107
- const root = await canonicalRuntimeRoot(requestedRoot);
108
- const harnesses = parseRuntimeHarnesses(options.harness);
109
- if (!options.yes) {
110
- if (!process.stdin.isTTY)
111
- throw new Error("Use --yes to authorize local execution");
112
- const rl = createInterface({ input: process.stdin, output: process.stderr });
113
- try {
114
- const answer = await rl.question(`Connect ${root}? Space collaborators can run commands as your OS user, beyond this folder. [y/N] `);
115
- if (!/^y(es)?$/i.test(answer.trim()))
116
- return;
117
- }
118
- finally {
119
- rl.close();
120
- }
121
- }
122
- const capabilities = await discoverHarnesses(harnesses, options, root);
123
- const client = createClient();
124
- const requested = options.space?.trim() || explicitSpace(program);
125
- const validateLocalRuntime = async (spaceId) => {
126
- const sandbox = (await client.space(spaceId).sandbox.get()).sandbox;
127
- if (sandbox?.provider !== "local")
128
- throw new Error("Space does not have a local Runtime");
129
- };
130
- const { spaceId } = await resolveRuntimeSpace({
131
- root,
132
- identityKey: currentIdentityKey(),
133
- explicitSpaceId: requested,
134
- createSpace: async () => (await client.spaces.create({
135
- name: resolveLocalSpaceName(root, options.name),
136
- config: { sandbox: { provider: "local" } },
137
- })).space.id,
138
- validateSpace: validateLocalRuntime,
139
- });
140
- const spaceClient = client.space(spaceId);
141
- const store = new RuntimeSessionStore(spaceId, { projectionSource: spaceClient });
142
- const runtimeId = randomUUID();
143
- const diagnostics = new RuntimeDiagnostics({ root: store.root, spaceId, runtimeId });
144
- store.setDiagnostics(diagnostics);
145
- diagnostics.log("info", "runtime.cli_started", {
146
- platform: process.platform,
147
- arch: process.arch,
148
- node: process.versions.node,
149
- harnesses,
150
- proxyConfigured: ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"].some((key) => Boolean(process.env[key]?.trim())),
151
- });
35
+ await runtimeUp(program, dir, { ...options, json: jsonRequested(options) });
36
+ }
37
+ catch (cause) {
38
+ reportFailure(cause);
39
+ }
40
+ });
41
+ for (const action of ["attach", "detach"])
42
+ runtime.command(action)
43
+ .description(action === "attach" ? "Sync native Pi / Codex Turns / 同步原生 Pi / Codex 对话" : "Pause native sync; retain all receipts / 暂停原生同步,保留所有回执")
44
+ .option("-s, --space <id>", "Target Space / 目标 Space")
45
+ .option("--harness <name>", "Pi or Codex; repeatable / 可重复指定", (value, previous) => [...previous, value], [])
46
+ .option("--pi <path>", "Pi executable for capability checks / 用于能力检查的 Pi 路径")
47
+ .option("--codex <path>", "Codex executable for capability checks / 用于能力检查的 Codex 路径")
48
+ .option("-y, --yes", "Authorize project conversation and native archive uploads / 授权上传项目对话及原生归档")
49
+ .option("--json", "JSON output / JSON 输出")
50
+ .action(async (options) => {
152
51
  try {
153
- const binary = await ensureSandboxdBinary({
154
- onStatus: (message) => diagnostics.log("info", "sandboxd.download", { message }, { component: "sandboxd" }),
155
- });
156
- const wsBase = resolveWebsocketUrl({ url: process.env.COHUB_WS_URL });
157
- const url = new URL(wsBase);
158
- url.pathname = "/runtime/relay";
159
- const relay = new URL(wsBase);
160
- relay.pathname = "/sandbox/relay";
161
- let bridge = null;
162
- let bridgeClosed = Promise.resolve();
163
- let announced = false;
164
- const token = await requireAccessToken();
165
- try {
166
- await serveRuntime({
167
- spaceId,
168
- cwd: root,
169
- url: url.toString(),
170
- capabilities,
171
- harnesses: options,
172
- runtimeId,
173
- diagnostics,
174
- token: requireAccessToken,
175
- signal: controller.signal,
176
- store,
177
- onReady: () => {
178
- if (!bridge) {
179
- bridge = spawn(binary, ["--local", "--space", spaceId, "--root", root, "--relay", process.env.COHUB_RELAY_URL?.trim() || relay.toString()], {
180
- stdio: ["ignore", "pipe", "pipe"],
181
- env: {
182
- ...process.env,
183
- COHUB_RELAY_TOKEN: token,
184
- COHUB_RUNTIME_ID: runtimeId,
185
- COHUB_LOG_FORMAT: "json",
186
- },
187
- });
188
- captureSandboxOutput(bridge.stdout, "stdout", diagnostics);
189
- captureSandboxOutput(bridge.stderr, "stderr", diagnostics);
190
- bridgeClosed = new Promise((resolveClosed) => bridge?.once("close", () => resolveClosed()));
191
- bridge.on("error", (cause) => {
192
- diagnostics.log("error", "sandboxd.process_error", { error: serializeDiagnosticError(cause) }, { component: "sandboxd" });
193
- console.error(cause);
194
- controller.abort();
195
- });
196
- bridge.once("exit", (code, signal) => {
197
- diagnostics.log(code === 0 ? "info" : "error", "sandboxd.process_exit", { code, signal }, { component: "sandboxd" });
198
- controller.abort();
199
- });
200
- }
201
- if (announced)
202
- return;
203
- announced = true;
204
- const webUrl = `${resolveCohubEnvironment() === "prod" ? "https://cohub.live" : "https://dev.cohub.live"}/spaces/${spaceId}`;
205
- if (jsonRequested(options))
206
- outJson({ spaceId, root, harnesses, runtimeId, diagnosticsPath: diagnostics.logPath, url: webUrl });
207
- else
208
- console.error(`Runtime connected: ${webUrl} (runtimeId=${runtimeId}, logs=${diagnostics.logPath})`);
209
- },
210
- });
211
- }
212
- finally {
213
- if (bridge) {
214
- const child = bridge;
215
- child.kill("SIGTERM");
216
- const timeout = setTimeout(() => child.kill("SIGKILL"), 3000);
217
- await bridgeClosed;
218
- clearTimeout(timeout);
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 / 请先使用 runtime up --space 绑定当前目录");
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 / 请先启动当前目录的 Runtime");
62
+ if (action === "attach" && !instance?.nativeSync)
63
+ throw new Error("Restart the Runtime with this CLI before attaching / 请先使用新版 CLI 重启 Runtime,再接入原生客户端");
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 / 请先通过 runtime up 启用对应 Harness");
67
+ if (action === "attach" && !options.yes) {
68
+ if (!process.stdin.isTTY)
69
+ throw new Error("Use --yes to authorize native sync / 请使用 --yes 授权原生同步");
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]\n安装用户级原生集成,并将当前项目打开的对话、工具输出和原始归档上传至此 Space?历史可能包含敏感信息。[y/N] `);
73
+ if (!/^y(es)?$/i.test(answer.trim()))
74
+ return;
75
+ }
76
+ finally {
77
+ rl.close();
219
78
  }
220
79
  }
80
+ const result = await installNativeSync({ root, spaceId, identity, harnesses, disabled: action === "detach", executables: { pi: options.pi, codex: options.codex } });
81
+ if (jsonRequested(options))
82
+ outJson(result);
83
+ else
84
+ process.stdout.write(action === "attach"
85
+ ? `Native sync enabled. Reload Pi or restart Codex and review its hook trust prompt. / 原生同步已启用。请重载 Pi 或重启 Codex,并审核 Hook 信任提示。\n${result.configPath}\n`
86
+ : "Native sync paused; all local records retained / 原生同步已暂停,所有本地记录已保留\n");
221
87
  }
222
88
  catch (cause) {
223
- diagnostics.log("error", "runtime.start_failed", { error: serializeDiagnosticError(cause) });
224
- throw cause;
89
+ reportFailure(cause);
225
90
  }
226
- finally {
227
- await diagnostics.close().catch((error) => console.error("Runtime diagnostics close failed:", error));
91
+ });
92
+ runtime.command("status").description("Local and server status / 本地与服务端状态")
93
+ .option("-s, --space <id>", "Target Space / 目标 Space")
94
+ .option("--json", "JSON output / JSON 输出")
95
+ .action(async (options) => {
96
+ try {
97
+ const spaceId = await resolveRuntimeTarget(program, options.space);
98
+ const identity = currentIdentityKey();
99
+ const local = identity ? await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId)) : null;
100
+ const space = createClient().space(spaceId);
101
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: space });
102
+ const [remote, pendingLocalArchives, failedLocalArchives, nativeStores] = await Promise.all([
103
+ space.getRuntime(undefined, { signal: AbortSignal.timeout(5000) }).then((value) => ({ value, error: null })).catch((error) => ({ value: null, error: serializeDiagnosticError(error).message })),
104
+ store.archives.pendingCount(), store.archives.failedCaptureCount(),
105
+ identity ? listNativeSyncStores(store.root, spaceId, identity) : [],
106
+ ]);
107
+ 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 };
109
+ if (jsonRequested(options))
110
+ outJson(result);
111
+ else {
112
+ if (local)
113
+ printRuntimeSummary(local);
114
+ else
115
+ process.stdout.write(`Local process / 本地进程 Not running / 未运行\nSpace / 空间 ${spaceId}\nLogs / 日志 ${result.diagnosticsPath}\n`);
116
+ process.stdout.write(`Server / 服务端 ${remote.error ? `Unknown / 未知 — ${remote.error}` : remote.value?.online ? "Harness connected / Harness 已连接" : "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 / Turn 待同步 · ${nativeSessions.reduce((sum, session) => sum + session.pendingArchives, 0)} archives pending / 归档待同步\n`);
228
119
  }
229
120
  }
230
121
  catch (cause) {
231
- if (!controller.signal.aborted)
232
- error("Runtime failed", cause instanceof Error ? cause.message : String(cause));
122
+ reportFailure(cause);
233
123
  }
234
- finally {
235
- process.removeListener("SIGINT", stop);
236
- process.removeListener("SIGTERM", stop);
237
- }
238
- });
239
- runtime.command("status").description("Runtime status").option("-s, --space <id>", "Target Space").action(async (options) => {
240
- const spaceId = options.space?.trim() || await resolveSpace(program);
241
- const client = createClient();
242
- const spaceClient = client.space(spaceId);
243
- const store = new RuntimeSessionStore(spaceId, { projectionSource: spaceClient });
244
- const [status, pendingLocalArchives, failedLocalArchives] = await Promise.all([
245
- spaceClient.getRuntime(),
246
- store.archives.pendingCount(),
247
- store.archives.failedCaptureCount(),
248
- ]);
249
- outJson({
250
- ...status,
251
- diagnosticsPath: runtimeDiagnosticsDirectory(store.root),
252
- pendingLocalArchives,
253
- failedLocalArchives,
254
- });
255
124
  });
256
- runtime.command("logs")
257
- .description("Read local Runtime diagnostics")
258
- .option("-s, --space <id>", "Target Space")
259
- .option("-l, --limit <count>", "Number of events", "100")
260
- .option("--follow", "Keep watching for new events")
261
- .option("--json", "Print raw diagnostic events")
125
+ runtime.command("down").description("Stop this local Runtime; retain all data / 停止本地 Runtime,保留所有数据")
126
+ .option("-s, --space <id>", "Target Space / 目标 Space")
127
+ .option("-y, --yes", "Stop even with unconfirmed executions / 确认停止包含未确认执行的 Runtime")
128
+ .option("--json", "JSON output / JSON 输出")
262
129
  .action(async (options) => {
263
- const spaceId = options.space?.trim() || await resolveSpace(program);
264
- const store = new RuntimeSessionStore(spaceId, { projectionSource: createClient().space(spaceId) });
265
- const limit = Number(options.limit ?? "100");
266
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
267
- return error("Invalid diagnostic limit", "Use an integer between 1 and 10000 / 使用 1 到 10000 之间的整数");
268
- const asJson = jsonRequested(options);
269
- const reader = options.follow ? new RuntimeDiagnosticReader(store.root) : null;
270
- const render = async () => {
271
- const fresh = options.follow
272
- ? await reader?.read({ limit }) ?? []
273
- : await readRuntimeDiagnosticEvents(store.root, { limit });
274
- if (options.follow && fresh.length === 0)
275
- return;
276
- if (asJson && options.follow) {
277
- for (const event of fresh)
278
- process.stdout.write(`${JSON.stringify(event)}\n`);
279
- }
280
- else if (asJson) {
281
- outJson(fresh);
282
- }
283
- else if (fresh.length === 0) {
284
- process.stdout.write("No Runtime diagnostics / 未找到 Runtime 诊断记录\n");
285
- }
286
- else {
287
- for (const event of fresh)
288
- printDiagnostic(event);
130
+ try {
131
+ const spaceId = await resolveRuntimeTarget(program, options.space);
132
+ const identity = currentIdentityKey();
133
+ if (!identity)
134
+ throw new Error("Sign in to the Runtime account / 请登录 Runtime 所属账号");
135
+ const directory = runtimeInstanceDirectory(identity, spaceId);
136
+ const local = await requestRuntimeInstance(directory, "stop", Boolean(options.yes));
137
+ const until = Date.now() + 15_000;
138
+ let running = Boolean(local);
139
+ while (running && Date.now() < until) {
140
+ await delay(250);
141
+ try {
142
+ running = Boolean(await requestRuntimeInstance(directory));
143
+ }
144
+ catch {
145
+ running = true;
146
+ } // An unreachable control socket does not prove the process stopped.
289
147
  }
290
- };
148
+ if (running)
149
+ throw new Error("Runtime is still stopping; inspect logs / Runtime 仍在停止,请检查日志");
150
+ if (jsonRequested(options))
151
+ outJson({ spaceId, stopped: true });
152
+ else
153
+ process.stdout.write("Runtime stopped; data retained / Runtime 已停止,数据已保留\n");
154
+ }
155
+ catch (cause) {
156
+ reportFailure(cause);
157
+ }
158
+ });
159
+ runtime.command("logs").description("Read local Runtime diagnostics / 查看本地 Runtime 日志")
160
+ .option("-s, --space <id>", "Target Space / 目标 Space")
161
+ .option("-l, --limit <count>", "Number of events / 事件数量", "100")
162
+ .option("--level <level>", "Minimum level: debug, info, warn, error / 最低级别", "info")
163
+ .option("-f, --follow", "Keep watching / 持续查看")
164
+ .option("--json", "Raw diagnostic events / 原始诊断事件")
165
+ .action(async (options) => {
166
+ const controller = new AbortController();
167
+ const stop = () => controller.abort();
291
168
  try {
292
- await render();
293
- if (!options.follow)
294
- return;
295
- await new Promise((resolve) => {
296
- let timer = null;
297
- let stopped = false;
298
- const stop = () => {
299
- stopped = true;
300
- if (timer)
301
- clearTimeout(timer);
302
- process.removeListener("SIGINT", stop);
303
- process.removeListener("SIGTERM", stop);
304
- resolve();
305
- };
306
- const poll = async () => {
307
- if (stopped)
308
- return;
309
- try {
310
- await render();
311
- }
312
- catch {
313
- stop();
314
- return;
315
- }
316
- if (!stopped)
317
- timer = setTimeout(() => void poll(), 2_000);
318
- };
319
- timer = setTimeout(() => void poll(), 2_000);
320
- process.once("SIGINT", stop);
321
- process.once("SIGTERM", stop);
322
- });
169
+ const spaceId = await resolveRuntimeTarget(program, options.space);
170
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: createClient().space(spaceId) });
171
+ const limit = Number(options.limit);
172
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
173
+ throw new Error("Use a limit from 1 to 10000 / 数量范围为 1 到 10000");
174
+ if (!diagnosticLevels.includes(options.level))
175
+ throw new Error("Use debug, info, warn or error / 请使用有效日志级别");
176
+ const asJson = jsonRequested(options);
177
+ const reader = new RuntimeDiagnosticReader(store.root);
178
+ process.once("SIGINT", stop);
179
+ process.once("SIGTERM", stop);
180
+ do {
181
+ const events = (options.follow ? await reader.read({ limit }) : await readRuntimeDiagnosticEvents(store.root, { limit }))
182
+ .filter((event) => atLeastLevel(event.level, options.level));
183
+ if (asJson && !options.follow)
184
+ outJson(events);
185
+ else
186
+ for (const event of events)
187
+ process.stdout.write(asJson ? `${JSON.stringify(event)}\n` : formatDiagnostic(event, true));
188
+ if (!options.follow) {
189
+ if (!asJson && !events.length)
190
+ process.stdout.write("No matching diagnostics / 未找到匹配日志\n");
191
+ break;
192
+ }
193
+ await delay(1000, undefined, { signal: controller.signal }).catch(() => undefined);
194
+ } while (!controller.signal.aborted);
323
195
  }
324
196
  catch (cause) {
325
- error("Runtime logs failed", cause instanceof Error ? cause.message : String(cause));
197
+ reportFailure(cause);
198
+ }
199
+ finally {
200
+ process.removeListener("SIGINT", stop);
201
+ process.removeListener("SIGTERM", stop);
326
202
  }
327
203
  });
328
204
  }
@@ -1,4 +1,4 @@
1
- export declare const SANDBOXD_VERSION = "v2.53.1";
1
+ export declare const SANDBOXD_VERSION = "v2.54.0";
2
2
  export declare class SandboxdDownloadError extends Error {
3
3
  name: string;
4
4
  }
@@ -15,11 +15,13 @@ import { Readable } from "node:stream";
15
15
  // that tag's publish-cdn job has succeeded, otherwise `runtime up` 404s on the
16
16
  // default download.
17
17
  //
18
- // v2.53.1 is the first published tag with the native FSEvents file-monitoring
19
- // backends and the `runtimeId` control frame (earlier `v2.52.0` predates them,
20
- // and the old sandbox-only `v1.x` line stopped at `v1.82.4`). Running anything
21
- // older keeps exhausting file descriptors on macOS.
22
- export const SANDBOXD_VERSION = "v2.53.1";
18
+ // v2.54.0 is the first published tag with the private managed Runtime control
19
+ // pipe (`COHUB_RUNTIME_MANAGED` over fd 3), so `runtime up` reads connection
20
+ // state from the daemon instead of polling the API every five seconds. It also
21
+ // carries the optional workspace-search runner download. v2.53.1 already has
22
+ // the native FSEvents backends and the `runtimeId` control frame, and older
23
+ // releases stay usable through the compatibility readiness/restart path.
24
+ export const SANDBOXD_VERSION = "v2.54.0";
23
25
  const BINARY_NAME = "cohub-sandboxd";
24
26
  // Public CDN prefix hosting the release archives (the repo is private, so the
25
27
  // GitHub Release assets are not publicly downloadable). Overridable for staging
@@ -38,6 +38,8 @@ export declare class RuntimeArchiveStore {
38
38
  harness: "pi" | "codex";
39
39
  nativeSessionId: string;
40
40
  path: string;
41
+ sizeBytes?: number;
42
+ expectedChecksum?: string;
41
43
  }, turnId: string): Promise<HarnessArchive>;
42
44
  private capture;
43
45
  flush(signal: AbortSignal): Promise<void>;
@@ -115,6 +115,8 @@ export class RuntimeArchiveStore {
115
115
  if (saved) {
116
116
  if (saved.sessionId !== state.sessionId || saved.harness !== state.harness)
117
117
  throw new Error("Archive identity mismatch");
118
+ if (state.expectedChecksum && state.expectedChecksum !== saved.sha256)
119
+ throw new Error("Native Turn bytes changed; original archive retained");
118
120
  const committed = await stat(join(this.root, "ready", `${turnId}.json`)).catch((error) => { if (missing(error))
119
121
  return null; throw error; });
120
122
  if (!committed)
@@ -130,12 +132,15 @@ export class RuntimeArchiveStore {
130
132
  const before = await file.stat();
131
133
  if (!before.isFile() || !before.size)
132
134
  throw new Error("Native archive is empty");
135
+ const sizeBytes = state.sizeBytes ?? before.size;
136
+ if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1 || sizeBytes > before.size)
137
+ throw new Error("Native archive boundary is unavailable");
133
138
  const buffer = Buffer.alloc(RUNTIME_ARCHIVE_SEGMENT_BYTES);
134
139
  let offset = 0;
135
140
  let digest = createHash("sha256");
136
141
  let parent = null;
137
142
  // Hash the old prefix, not just its size: equal-size and growing rewrites are valid.
138
- if (previous && previous.nativeSessionId === state.nativeSessionId && previous.sizeBytes <= before.size) {
143
+ if (previous && previous.nativeSessionId === state.nativeSessionId && previous.sizeBytes <= sizeBytes) {
139
144
  while (offset < previous.sizeBytes) {
140
145
  const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, previous.sizeBytes - offset), offset);
141
146
  if (!bytesRead)
@@ -152,8 +157,8 @@ export class RuntimeArchiveStore {
152
157
  }
153
158
  const segments = [];
154
159
  await mkdir(join(this.root, "objects"), { recursive: true, mode: 0o700 });
155
- while (offset < before.size) {
156
- const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, before.size - offset), offset);
160
+ while (offset < sizeBytes) {
161
+ const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, sizeBytes - offset), offset);
157
162
  if (!bytesRead)
158
163
  throw new Error("Native file changed during capture");
159
164
  const bytes = buffer.subarray(0, bytesRead);
@@ -164,8 +169,16 @@ export class RuntimeArchiveStore {
164
169
  offset += bytesRead;
165
170
  }
166
171
  const after = await stat(state.path);
167
- if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
172
+ if (before.ino !== after.ino || after.size < sizeBytes || state.sizeBytes === undefined && (before.size !== after.size || before.mtimeMs !== after.mtimeMs))
168
173
  throw new Error("Native file changed during capture");
174
+ if (state.sizeBytes !== undefined) {
175
+ // Native clients may append while an earlier Turn is captured. Validate the exact prefix twice.
176
+ const verified = createHash("sha256");
177
+ for await (const bytes of createReadStream(state.path, { end: sizeBytes - 1 }))
178
+ verified.update(bytes);
179
+ if (verified.digest("hex") !== digest.copy().digest("hex"))
180
+ throw new Error("Native prefix changed during capture");
181
+ }
169
182
  if (process.platform !== "win32") {
170
183
  const directory = await open(join(this.root, "objects"), "r");
171
184
  try {
@@ -177,8 +190,10 @@ export class RuntimeArchiveStore {
177
190
  }
178
191
  index = harnessArchiveIndexSchema.parse({ ...identity, version: 1, nativeSessionId: state.nativeSessionId,
179
192
  nativeFormat: state.harness === "pi" ? "pi.jsonl" : "codex.rollout", parentTurnId: parent?.turnId ?? null,
180
- sizeBytes: before.size, sha256: digest.digest("hex"), segments });
193
+ sizeBytes, sha256: digest.digest("hex"), segments });
181
194
  validateArchiveBoundary(index, parent);
195
+ if (state.expectedChecksum && index.sha256 !== state.expectedChecksum)
196
+ throw new Error("Native Turn bytes changed during capture; original retained");
182
197
  }
183
198
  finally {
184
199
  await file.close();
@@ -254,7 +269,6 @@ export class RuntimeArchiveStore {
254
269
  catch (error) {
255
270
  if (!signal.aborted) {
256
271
  this.errorReporter?.(error, index);
257
- console.error("Archive pending; native segments retained:", error);
258
272
  }
259
273
  }
260
274
  }