@neta-art/cohub-cli 7.0.0 → 7.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
@@ -72,33 +72,41 @@ cohub -s <spaceId> spaces invites revoke <code> --yes
72
72
  cohub -s <spaceId> run -- git status
73
73
  ```
74
74
 
75
- Many space-scoped commands need a target Space:
75
+ Many space-scoped commands need a target Space. Without an explicit target, commands
76
+ also use the Space remembered for the current directory before falling back to Home:
76
77
 
77
78
  ```bash
78
79
  cohub -s <spaceId> spaces prompt "message" --json
79
80
  COHUB_SPACE_ID=<spaceId> cohub spaces prompt "message" --json
80
81
  ```
81
82
 
82
- ## Local Runtime / 本地 Runtime
83
+ ## Local Runtime
83
84
 
84
85
  Connect one local workspace to a Space and select Pi or Codex per turn.
85
- 将一个本地工作区连接到 Space,每轮可选择 Pi 或 Codex。
86
86
 
87
87
  ```bash
88
88
  cohub runtime up ./project --harness pi --harness codex
89
89
  cohub runtime up ./project --space <spaceId> --harness codex
90
- cohub -s <spaceId> spaces prompt "Continue / 继续" --harness codex
90
+ cohub -s <spaceId> spaces prompt "Continue" --harness codex
91
91
  cohub runtime status --space <spaceId>
92
+ cohub runtime logs --space <spaceId> --follow
92
93
  ```
93
94
 
95
+ Runtime diagnostics stay as redacted JSONL under the local Runtime state directory and
96
+ are never uploaded automatically. Use `runtime logs --json` to export a failure report;
97
+ events carry the local `runtimeId` plus server `requestId` / `traceparent` when a turn has
98
+ reached the Agent trace.
99
+
94
100
  The Runtime uses WebSockets, native Pi RPC / Codex app-server, and existing cloud
95
101
  streaming. Local executables and credentials are required. `sandbox up` has been
96
102
  removed. Reconnection and result reconciliation are automatic; no recovery command is needed.
97
- See [Runtime details](../../docs/local-runtime.md).
103
+ When a result genuinely cannot be determined, the affected Chat offers an explicit stop
104
+ confirmation. See [Runtime details](../../docs/local-runtime.md).
98
105
 
99
- Runtime 使用 WebSocket、原生 Pi RPC / Codex app-server 和现有云端流式链路。
100
- 需要本机已安装并登录对应程序。Runtime 断线重连和结果核对自动完成,无需恢复命令。
101
- 仅在无法确定结果时,由 Space 页头提供异常确认。旧 `sandbox up` 命令已移除。
106
+ When `--space` is omitted, Runtime remembers a Space for the canonical local directory,
107
+ account, and environment in `~/.config/cohub/runtime-spaces.json`. The first start creates
108
+ and records a local Space; later starts reuse it. An explicit `--space` or `COHUB_SPACE_ID`
109
+ overrides and updates the directory binding.
102
110
 
103
111
  ## Chats and prompts
104
112
 
@@ -378,7 +386,7 @@ legacy bare targets. A bare target checks the current Space for a file first, th
378
386
  falls back to the same App references as `apps get`. Showing a preview is
379
387
  idempotent: repeating it re-activates the same tab and refreshes any launch state
380
388
  carried by the reference. With `--call`, the command waits for the App to announce readiness, invokes the method,
381
- and waits for the App to complete the same UI command with `client.ui.reportResult()`.
389
+ and waits for the App to complete the same UI command with `client.desktop.reportResult()`.
382
390
 
383
391
  App authors decide what is callable by registering handlers inside the App:
384
392
 
@@ -508,7 +508,7 @@ export function registerApps(program) {
508
508
  .option("--app-scope <scope>", "Scope granted directly to the app runtime (space.view, session.view, file.view, file.edit, taskrun.view, session.prompt.readonly, session.prompt.fullaccess, command.execute)", collectOption, [])
509
509
  .option("--viewer-scope <scope>", "Deprecated: viewer grants are no longer gated by the app configuration", collectOption, [])
510
510
  .option("--clear-app-scopes", "Clear app runtime scopes")
511
- .option("--clear-viewer-scopes", "Deprecated: clear legacy scope metadata / 已废弃:清除旧权限元数据")
511
+ .option("--clear-viewer-scopes", "Deprecated: clear legacy scope metadata")
512
512
  .option("--meta <json>", "App metadata as a JSON object")
513
513
  .option("--hide-cohub-bar", "Hide the Cohub footer bar on the public app page")
514
514
  .option("--show-cohub-bar", "Show the Cohub footer bar on the public app page")
@@ -728,7 +728,7 @@ export function registerApps(program) {
728
728
  .description("Grant an app scopes as the current user")
729
729
  .requiredOption("--scope <scope>", "Scope to grant (repeatable)", collectOption, [])
730
730
  .option("--space <spaceId>", "Target space; defaults to the app's own space")
731
- .option("--extend", "Add scopes without replacing active grants / 增加权限,保留有效授权")
731
+ .option("--extend", "Add scopes without replacing active grants")
732
732
  .option("--json", "Output as JSON")
733
733
  .action(async (appRef, opts) => {
734
734
  const client = createClient();
@@ -1,5 +1,14 @@
1
1
  import { resolveOpenSurface } from "@neta-art/cohub";
2
2
  import type { Command } from "commander";
3
3
  export { resolveOpenSurface };
4
+ /**
5
+ * Optional disambiguation for file:// vs app://. Unlike ordinary
6
+ * Space-scoped commands, an unbound directory must not fall back to Home:
7
+ * the plain target should remain eligible for App resolution.
8
+ */
9
+ export declare function resolveOptionalSpaceId(command: Command, options?: {
10
+ cwd?: string;
11
+ bindingsPath?: string;
12
+ }): Promise<string | undefined>;
4
13
  export declare function registerDesktop(program: Command): void;
5
14
  export declare function registerLegacyUi(program: Command): void;
@@ -2,13 +2,18 @@ import { readFileSync } from "node:fs";
2
2
  import { HttpError, parseAppRef, resolveOpenSurface, DESKTOP_COMMAND_DEFAULT_TIMEOUT_MS, DESKTOP_COMMAND_MAX_TIMEOUT_MS, } from "@neta-art/cohub";
3
3
  import { createClient } from "../client.js";
4
4
  import { error, handleHttp, json as outJson, jsonRequested, ok } from "../output.js";
5
+ import { resolveBoundSpace } from "../space.js";
5
6
  import { getAppByRef } from "../app-ref.js";
6
7
  export { resolveOpenSurface };
7
8
  const FILE_SCHEME = "file://";
8
9
  const APP_SCHEME = "app://";
9
10
  const LEGACY_WORK_SCHEME = "work://";
10
- /** Optional disambiguation for file:// vs app:// — do not fall back to Home. */
11
- function optionalSpaceId(command) {
11
+ /**
12
+ * Optional disambiguation for file:// vs app://. Unlike ordinary
13
+ * Space-scoped commands, an unbound directory must not fall back to Home:
14
+ * the plain target should remain eligible for App resolution.
15
+ */
16
+ export async function resolveOptionalSpaceId(command, options = {}) {
12
17
  let current = command;
13
18
  while (current) {
14
19
  const opts = current.opts();
@@ -16,7 +21,10 @@ function optionalSpaceId(command) {
16
21
  return opts.space.trim();
17
22
  current = current.parent ?? null;
18
23
  }
19
- return process.env.COHUB_SPACE_ID?.trim() || undefined;
24
+ const fromEnvironment = process.env.COHUB_SPACE_ID?.trim();
25
+ if (fromEnvironment)
26
+ return fromEnvironment;
27
+ return (await resolveBoundSpace(options)) ?? undefined;
20
28
  }
21
29
  function parseFilePath(value) {
22
30
  const path = value.slice(FILE_SCHEME.length).trim();
@@ -86,7 +94,7 @@ async function resolveOpenTarget(client, command, value) {
86
94
  return { kind: "file", path: parseFilePath(value) };
87
95
  if (hasAppScheme(value))
88
96
  return resolveAppTarget(client, value);
89
- const spaceId = optionalSpaceId(command);
97
+ const spaceId = await resolveOptionalSpaceId(command);
90
98
  if (spaceId) {
91
99
  try {
92
100
  await client.space(spaceId).files.read(value);
@@ -190,7 +198,7 @@ const OPEN_NOTES = `
190
198
  Notes:
191
199
  - Use file:// and app:// to make the target explicit; the legacy work://
192
200
  scheme is still accepted.
193
- - A plain target checks the current Space for a file before resolving an app.
201
+ - A plain target checks the explicit Space or current directory Runtime binding for a file before resolving an app; it does not fall back to Home.
194
202
  - Opening a window is idempotent; repeating it re-activates the same tab.
195
203
  - --as picks the surface: window (a preview tab) or overlay (a transparent
196
204
  layer above the workspace). Without it, an App published with
@@ -1,2 +1,13 @@
1
+ type RunCliOptions = {
2
+ spaceId: string;
3
+ json: boolean;
4
+ async: boolean;
5
+ command: string;
6
+ };
7
+ export declare function parseRunCliOptions(argv: string[], spaceOptions?: {
8
+ cwd?: string;
9
+ bindingsPath?: string;
10
+ }): Promise<RunCliOptions>;
1
11
  export declare function printRunHelp(): void;
2
12
  export declare function maybeHandleRunCommand(argv: string[]): Promise<boolean>;
13
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { createClient } from "../client.js";
2
2
  import { error, handleHttp, json as outJson, spinner } from "../output.js";
3
- import { missingSpaceError, resolveDefaultSpace } from "../space.js";
3
+ import { resolveSpaceTarget } from "../space.js";
4
4
  const DEFAULT_WAIT_TIMEOUT_MS = (6 * 60 * 60 + 60) * 1000;
5
5
  const DEFAULT_POLL_INTERVAL_MS = 1500;
6
6
  function shellQuote(value) {
@@ -44,7 +44,7 @@ function parseSpaceId(tokens) {
44
44
  }
45
45
  return undefined;
46
46
  }
47
- async function parseRunCliOptions(argv) {
47
+ export async function parseRunCliOptions(argv, spaceOptions = {}) {
48
48
  const runIndex = topLevelRunIndex(argv);
49
49
  if (runIndex < 0)
50
50
  return error("Invalid invocation", "Use `cohub run [options] <command>`");
@@ -104,7 +104,7 @@ async function parseRunCliOptions(argv) {
104
104
  if (!command) {
105
105
  return error("No command", "Pass --command <shell command>, or use `--` followed by the command.");
106
106
  }
107
- const spaceId = explicitSpaceId || (await resolveDefaultSpace().catch(handleHttp)) || missingSpaceError();
107
+ const spaceId = await resolveSpaceTarget(explicitSpaceId, spaceOptions);
108
108
  return { spaceId, json, async, command };
109
109
  }
110
110
  export function printRunHelp() {
@@ -125,7 +125,7 @@ Examples:
125
125
  cohub -s <spaceId> run -- git status -sb
126
126
 
127
127
  Notes:
128
- - Without -s or COHUB_SPACE_ID, the command targets your Home space.
128
+ - Without -s or COHUB_SPACE_ID, the command uses the current directory Runtime binding, then Home.
129
129
  - Use --command for commands that contain leading flags, or use -- before the shell command.
130
130
  - The command runs in /workspace.
131
131
  `);
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { stat } from "node:fs/promises";
3
4
  import { basename, resolve } from "node:path";
4
5
  import { createInterface } from "node:readline/promises";
@@ -6,45 +7,111 @@ import { isLocalHarness, resolveCohubEnvironment, resolveWebsocketUrl } from "@n
6
7
  import { requireAccessToken } from "../auth.js";
7
8
  import { createClient } from "../client.js";
8
9
  import { error, json as outJson, jsonRequested } from "../output.js";
9
- import { resolveSpace } from "../space.js";
10
+ import { currentIdentityKey, explicitSpace, resolveSpace } from "../space.js";
11
+ import { canonicalRuntimeRoot, resolveRuntimeSpace } from "../runtime/space-binding.js";
10
12
  import { discoverHarnesses } from "../runtime/harness.js";
11
13
  import { serveRuntime } from "../runtime/connection.js";
12
14
  import { RuntimeSessionStore } from "../runtime/session-store.js";
13
15
  import { ensureSandboxdBinary } from "./sandboxd-binary.js";
16
+ import { readRuntimeDiagnosticEvents, RuntimeDiagnosticReader, RuntimeDiagnostics, runtimeDiagnosticsDirectory, serializeDiagnosticError, } from "../runtime/diagnostics.js";
14
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
+ }
15
81
  export function parseRuntimeHarnesses(values) {
16
82
  const names = values.flatMap((value) => value.split(",")).map((name) => name.trim()).filter(Boolean);
17
83
  if (names.some((name) => !isLocalHarness(name)))
18
- throw new Error("Harness must be pi or codex / Harness 必须是 pi 或 codex");
84
+ throw new Error("Harness must be pi or codex");
19
85
  return [...new Set(names.length ? names : ["pi"])];
20
86
  }
21
87
  export function registerRuntime(program) {
22
- const runtime = program.command("runtime").description("Connect a local workspace / 连接本地工作区");
88
+ const runtime = program.command("runtime").description("Connect a local workspace");
23
89
  runtime.command("up [dir]")
24
- .description("Connect local Harnesses and files / 连接本地 Harness 与文件")
25
- .option("-s, --space <id>", "Target Space / 目标 Space")
26
- .option("-n, --name <name>", "New Space name / 新 Space 名称")
27
- .option("--harness <name>", "Pi or Codex; repeatable / Pi 或 Codex,可重复", (value, previous) => [...previous, value], [])
28
- .option("--pi <path>", "Pi executable / Pi 可执行文件")
29
- .option("--codex <path>", "Codex executable / Codex 可执行文件")
30
- .option("-y, --yes", "Accept local execution access / 同意本机执行权限")
31
- .option("--json", "JSON output / JSON 输出")
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")
32
98
  .action(async (dir, options) => {
33
99
  const controller = new AbortController();
34
100
  const stop = () => controller.abort();
35
101
  process.once("SIGINT", stop);
36
102
  process.once("SIGTERM", stop);
37
103
  try {
38
- const root = resolve(dir ?? process.cwd());
39
- if (!(await stat(root)).isDirectory())
40
- throw new Error("Workspace is not a directory / 工作区不是目录");
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);
41
108
  const harnesses = parseRuntimeHarnesses(options.harness);
42
109
  if (!options.yes) {
43
110
  if (!process.stdin.isTTY)
44
- throw new Error("Use --yes to authorize local execution / 请使用 --yes 授权本机执行");
111
+ throw new Error("Use --yes to authorize local execution");
45
112
  const rl = createInterface({ input: process.stdin, output: process.stderr });
46
113
  try {
47
- const answer = await rl.question(`Connect ${root}? Space collaborators can run commands as your OS user, beyond this folder.\n连接此目录?Space 协作者可使用当前系统用户执行命令,权限不限于此目录。 [y/N] `);
114
+ const answer = await rl.question(`Connect ${root}? Space collaborators can run commands as your OS user, beyond this folder. [y/N] `);
48
115
  if (!/^y(es)?$/i.test(answer.trim()))
49
116
  return;
50
117
  }
@@ -54,64 +121,208 @@ export function registerRuntime(program) {
54
121
  }
55
122
  const capabilities = await discoverHarnesses(harnesses, options, root);
56
123
  const client = createClient();
57
- const requested = options.space?.trim() || program.opts().space?.trim();
58
- const spaceId = requested || (await client.spaces.create({ name: resolveLocalSpaceName(root, options.name), config: { sandbox: { provider: "local" } } })).space.id;
59
- const sandbox = (await client.space(spaceId).sandbox.get()).sandbox;
60
- if (sandbox?.provider !== "local")
61
- throw new Error("Space does not have a local Runtime / Space 不是本地 Runtime");
62
- const binary = await ensureSandboxdBinary();
63
- const wsBase = resolveWebsocketUrl({ url: process.env.COHUB_WS_URL });
64
- const url = new URL(wsBase);
65
- url.pathname = "/runtime/relay";
66
- const relay = new URL(wsBase);
67
- relay.pathname = "/sandbox/relay";
68
- let bridge = null;
69
- let bridgeClosed = Promise.resolve();
70
- const token = await requireAccessToken();
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
+ });
71
152
  try {
72
- await serveRuntime({
73
- spaceId, cwd: root, url: url.toString(), capabilities, harnesses: options,
74
- token: requireAccessToken, signal: controller.signal, store: new RuntimeSessionStore(spaceId, undefined, client.space(spaceId)),
75
- onReady: () => {
76
- if (!bridge) {
77
- bridge = spawn(binary, ["--local", "--space", spaceId, "--root", root, "--relay", process.env.COHUB_RELAY_URL?.trim() || relay.toString()], { stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, COHUB_RELAY_TOKEN: token } });
78
- bridgeClosed = new Promise((resolveClosed) => bridge?.once("close", () => resolveClosed()));
79
- bridge.on("error", (cause) => { console.error(cause); controller.abort(); });
80
- bridge.once("exit", () => controller.abort());
81
- }
82
- const webUrl = `${resolveCohubEnvironment() === "prod" ? "https://cohub.live" : "https://dev.cohub.live"}/spaces/${spaceId}`;
83
- if (jsonRequested(options))
84
- outJson({ spaceId, root, harnesses, url: webUrl });
85
- else
86
- console.error(`Runtime connected / Runtime 已连接: ${webUrl}`);
87
- },
153
+ const binary = await ensureSandboxdBinary({
154
+ onStatus: (message) => diagnostics.log("info", "sandboxd.download", { message }, { component: "sandboxd" }),
88
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);
219
+ }
220
+ }
221
+ }
222
+ catch (cause) {
223
+ diagnostics.log("error", "runtime.start_failed", { error: serializeDiagnosticError(cause) });
224
+ throw cause;
89
225
  }
90
226
  finally {
91
- if (bridge) {
92
- const child = bridge;
93
- child.kill("SIGTERM");
94
- const timeout = setTimeout(() => child.kill("SIGKILL"), 3000);
95
- await bridgeClosed;
96
- clearTimeout(timeout);
97
- }
227
+ await diagnostics.close().catch((error) => console.error("Runtime diagnostics close failed:", error));
98
228
  }
99
229
  }
100
230
  catch (cause) {
101
231
  if (!controller.signal.aborted)
102
- error("Runtime failed / Runtime 失败", cause instanceof Error ? cause.message : String(cause));
232
+ error("Runtime failed", cause instanceof Error ? cause.message : String(cause));
103
233
  }
104
234
  finally {
105
235
  process.removeListener("SIGINT", stop);
106
236
  process.removeListener("SIGTERM", stop);
107
237
  }
108
238
  });
109
- runtime.command("status").description("Runtime status / Runtime 状态").option("-s, --space <id>", "Target Space / 目标 Space").action(async (options) => {
239
+ runtime.command("status").description("Runtime status").option("-s, --space <id>", "Target Space").action(async (options) => {
110
240
  const spaceId = options.space?.trim() || await resolveSpace(program);
111
- const { archives } = new RuntimeSessionStore(spaceId);
241
+ const client = createClient();
242
+ const spaceClient = client.space(spaceId);
243
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: spaceClient });
112
244
  const [status, pendingLocalArchives, failedLocalArchives] = await Promise.all([
113
- createClient().space(spaceId).getRuntime(), archives.pendingCount(), archives.failedCaptureCount(),
245
+ spaceClient.getRuntime(),
246
+ store.archives.pendingCount(),
247
+ store.archives.failedCaptureCount(),
114
248
  ]);
115
- outJson({ ...status, pendingLocalArchives, failedLocalArchives });
249
+ outJson({
250
+ ...status,
251
+ diagnosticsPath: runtimeDiagnosticsDirectory(store.root),
252
+ pendingLocalArchives,
253
+ failedLocalArchives,
254
+ });
255
+ });
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")
262
+ .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);
289
+ }
290
+ };
291
+ 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
+ });
323
+ }
324
+ catch (cause) {
325
+ error("Runtime logs failed", cause instanceof Error ? cause.message : String(cause));
326
+ }
116
327
  });
117
328
  }
@@ -2,6 +2,8 @@ export declare const SANDBOXD_VERSION = "v1.82.4";
2
2
  export declare class SandboxdDownloadError extends Error {
3
3
  name: string;
4
4
  }
5
+ export declare function validSandboxdArchiveEntries(entries: string[], version?: string): boolean;
6
+ export declare function validateSandboxdArchive(archivePath: string, version?: string): Promise<string[]>;
5
7
  export type EnsureSandboxdOptions = {
6
8
  version?: string;
7
9
  onStatus?: (message: string) => void;