@neta-art/cohub-cli 8.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +7 -16
  2. package/dist/commands/runtime.js +190 -87
  3. package/dist/commands/sandboxd-binary.d.ts +1 -1
  4. package/dist/commands/sandboxd-binary.js +8 -1
  5. package/dist/runtime/archive-store.d.ts +3 -1
  6. package/dist/runtime/archive-store.js +2 -0
  7. package/dist/runtime/connection.js +34 -22
  8. package/dist/runtime/diagnostics.js +8 -1
  9. package/dist/runtime/harness.d.ts +4 -0
  10. package/dist/runtime/harness.js +29 -16
  11. package/dist/runtime/instance.js +5 -5
  12. package/dist/runtime/json-rpc.d.ts +2 -0
  13. package/dist/runtime/json-rpc.js +2 -0
  14. package/dist/runtime/launch.js +29 -23
  15. package/dist/runtime/native-attach.d.ts +28 -0
  16. package/dist/runtime/native-attach.js +47 -0
  17. package/dist/runtime/native-codex-hook.js +3 -3
  18. package/dist/runtime/native-install.js +9 -9
  19. package/dist/runtime/native-ipc.d.ts +2 -0
  20. package/dist/runtime/native-ipc.js +7 -5
  21. package/dist/runtime/native-pi-extension.js +1 -1
  22. package/dist/runtime/native-sync-store.d.ts +6 -1
  23. package/dist/runtime/native-sync-store.js +24 -21
  24. package/dist/runtime/native-sync.d.ts +25 -0
  25. package/dist/runtime/native-sync.js +89 -8
  26. package/dist/runtime/native-transcript.d.ts +8 -0
  27. package/dist/runtime/native-transcript.js +45 -16
  28. package/dist/runtime/presentation.d.ts +10 -1
  29. package/dist/runtime/presentation.js +47 -29
  30. package/dist/runtime/process-group.d.ts +2 -0
  31. package/dist/runtime/process-group.js +124 -31
  32. package/dist/runtime/session-store.d.ts +15 -2
  33. package/dist/runtime/session-store.js +79 -5
  34. package/dist/runtime/space-binding.js +3 -3
  35. package/dist/runtime/supervisor.js +4 -5
  36. package/package.json +2 -2
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,35 +1,36 @@
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";
15
16
  const reportFailure = (cause) => {
16
- process.stderr.write(`Runtime failed / Runtime 失败: ${serializeDiagnosticError(cause).message}\n`);
17
+ process.stderr.write(`Runtime failed: ${serializeDiagnosticError(cause).message}\n`);
17
18
  process.exitCode = 1;
18
19
  };
19
20
  export function registerRuntime(program) {
20
- const runtime = program.command("runtime").description("Connect a local workspace / 连接本地工作区");
21
+ const runtime = program.command("runtime").description("Connect a local workspace");
21
22
  runtime.command("up [dir]")
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 输出")
23
+ .description("Connect local Harnesses and files")
24
+ .option("-s, --space <id>", "Target Space")
25
+ .option("-n, --new", "Create a new Space")
26
+ .option("--name <name>", "New Space name")
27
+ .option("-d, --detach", "Run in the background")
28
+ .option("--harness <name>", "Pi or Codex; repeatable", (value, previous) => [...previous, value], [])
29
+ .option("--pi <path>", "Pi executable")
30
+ .option("--codex <path>", "Codex executable")
31
+ .option("-y, --yes", "Accept defaults and authorize local execution and native sync")
32
+ .option("--verbose", "Show diagnostic details")
33
+ .option("--json", "JSON output")
33
34
  .action(async (dir, options) => {
34
35
  try {
35
36
  await runtimeUp(program, dir, { ...options, json: jsonRequested(options) });
@@ -38,100 +39,202 @@ 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 / 同步原生 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) => {
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 / 请先使用 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();
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. / 原生同步已启用。请重载 Pi 或重启 Codex,并审核 Hook 信任提示。\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;
87
128
  }
88
- catch (cause) {
89
- reportFailure(cause);
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
+ }
173
+ }
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
- });
92
- runtime.command("status").description("Local and server status / 本地与服务端状态")
93
- .option("-s, --space <id>", "Target Space / 目标 Space")
94
- .option("--json", "JSON output / JSON 输出")
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
+ });
194
+ runtime.command("status").description("Local and server status")
195
+ .option("-s, --space <id>", "Target Space")
196
+ .option("--json", "JSON output")
95
197
  .action(async (options) => {
96
198
  try {
97
199
  const spaceId = await resolveRuntimeTarget(program, options.space);
98
200
  const identity = currentIdentityKey();
99
201
  const local = identity ? await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId)) : null;
100
202
  const space = createClient().space(spaceId);
101
- const store = new RuntimeSessionStore(spaceId, { projectionSource: space });
203
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: space, archiveTransport: null });
102
204
  const [remote, pendingLocalArchives, failedLocalArchives, nativeStores] = await Promise.all([
103
205
  space.getRuntime(undefined, { signal: AbortSignal.timeout(5000) }).then((value) => ({ value, error: null })).catch((error) => ({ value: null, error: serializeDiagnosticError(error).message })),
104
206
  store.archives.pendingCount(), store.archives.failedCaptureCount(),
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 {
112
216
  if (local)
113
217
  printRuntimeSummary(local);
114
218
  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`);
219
+ process.stdout.write(`Local process Not running\nSpace ${spaceId}\nLogs ${result.diagnosticsPath}\n`);
220
+ process.stdout.write(`Server ${remote.error ? `Unknown — ${remote.error}` : remote.value?.online ? "Harness connected" : "Offline"}\nArchives ${pendingLocalArchives} pending · ${failedLocalArchives} failed\n`);
221
+ process.stdout.write(formatNativeSync(nativeSync.config, nativeSessions, nativeSync.error));
119
222
  }
120
223
  }
121
224
  catch (cause) {
122
225
  reportFailure(cause);
123
226
  }
124
227
  });
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 输出")
228
+ runtime.command("down").description("Stop this local Runtime; retain all data")
229
+ .option("-s, --space <id>", "Target Space")
230
+ .option("-y, --yes", "Stop even with unconfirmed executions")
231
+ .option("--json", "JSON output")
129
232
  .action(async (options) => {
130
233
  try {
131
234
  const spaceId = await resolveRuntimeTarget(program, options.space);
132
235
  const identity = currentIdentityKey();
133
236
  if (!identity)
134
- throw new Error("Sign in to the Runtime account / 请登录 Runtime 所属账号");
237
+ throw new Error("Sign in to the Runtime account");
135
238
  const directory = runtimeInstanceDirectory(identity, spaceId);
136
239
  const local = await requestRuntimeInstance(directory, "stop", Boolean(options.yes));
137
240
  const until = Date.now() + 15_000;
@@ -146,33 +249,33 @@ export function registerRuntime(program) {
146
249
  } // An unreachable control socket does not prove the process stopped.
147
250
  }
148
251
  if (running)
149
- throw new Error("Runtime is still stopping; inspect logs / Runtime 仍在停止,请检查日志");
252
+ throw new Error("Runtime is still stopping; inspect logs");
150
253
  if (jsonRequested(options))
151
254
  outJson({ spaceId, stopped: true });
152
255
  else
153
- process.stdout.write("Runtime stopped; data retained / Runtime 已停止,数据已保留\n");
256
+ process.stdout.write("Runtime stopped; data retained\n");
154
257
  }
155
258
  catch (cause) {
156
259
  reportFailure(cause);
157
260
  }
158
261
  });
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 / 原始诊断事件")
262
+ runtime.command("logs").description("Read local Runtime diagnostics")
263
+ .option("-s, --space <id>", "Target Space")
264
+ .option("-l, --limit <count>", "Number of events", "100")
265
+ .option("--level <level>", "Minimum level: debug, info, warn, error", "info")
266
+ .option("-f, --follow", "Keep watching")
267
+ .option("--json", "Raw diagnostic events")
165
268
  .action(async (options) => {
166
269
  const controller = new AbortController();
167
270
  const stop = () => controller.abort();
168
271
  try {
169
272
  const spaceId = await resolveRuntimeTarget(program, options.space);
170
- const store = new RuntimeSessionStore(spaceId, { projectionSource: createClient().space(spaceId) });
273
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: createClient().space(spaceId), archiveTransport: null });
171
274
  const limit = Number(options.limit);
172
275
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
173
- throw new Error("Use a limit from 1 to 10000 / 数量范围为 1 到 10000");
276
+ throw new Error("Use a limit from 1 to 10000");
174
277
  if (!diagnosticLevels.includes(options.level))
175
- throw new Error("Use debug, info, warn or error / 请使用有效日志级别");
278
+ throw new Error("Use debug, info, warn or error");
176
279
  const asJson = jsonRequested(options);
177
280
  const reader = new RuntimeDiagnosticReader(store.root);
178
281
  process.once("SIGINT", stop);
@@ -187,7 +290,7 @@ export function registerRuntime(program) {
187
290
  process.stdout.write(asJson ? `${JSON.stringify(event)}\n` : formatDiagnostic(event, true));
188
291
  if (!options.follow) {
189
292
  if (!asJson && !events.length)
190
- process.stdout.write("No matching diagnostics / 未找到匹配日志\n");
293
+ process.stdout.write("No matching diagnostics\n");
191
294
  break;
192
295
  }
193
296
  await delay(1000, undefined, { signal: controller.signal }).catch(() => undefined);
@@ -1,4 +1,4 @@
1
- export declare const SANDBOXD_VERSION = "v2.54.0";
1
+ export declare const SANDBOXD_VERSION = "v2.55.0";
2
2
  export declare class SandboxdDownloadError extends Error {
3
3
  name: string;
4
4
  }
@@ -21,7 +21,14 @@ import { Readable } from "node:stream";
21
21
  // carries the optional workspace-search runner download. v2.53.1 already has
22
22
  // the native FSEvents backends and the `runtimeId` control frame, and older
23
23
  // releases stay usable through the compatibility readiness/restart path.
24
- export const SANDBOXD_VERSION = "v2.54.0";
24
+ //
25
+ // v2.54.1 keeps the same wire protocol and adds relay diagnostics: data-channel
26
+ // pairing outlives the runner's dial timeout, dial failures distinguish a
27
+ // timeout from an explicit rejection, and teardown-time websocket write
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";
25
32
  const BINARY_NAME = "cohub-sandboxd";
26
33
  // Public CDN prefix hosting the release archives (the repo is private, so the
27
34
  // GitHub Release assets are not publicly downloadable). Overridable for staging
@@ -24,7 +24,9 @@ export declare class RuntimeArchiveStore {
24
24
  private flushing;
25
25
  private readonly capturing;
26
26
  private errorReporter;
27
- constructor(root: string, transport?: ArchiveTransport | undefined);
27
+ constructor(root: string, transport?: ArchiveTransport | null | undefined);
28
+ /** Distinguishes "no transport configured" from transient upload failures. */
29
+ get hasTransport(): boolean;
28
30
  setErrorReporter(reporter: ((error: unknown, index?: HarnessArchiveIndex) => void) | null): void;
29
31
  pendingCount(): Promise<number>;
30
32
  failedCaptureCount(): Promise<number>;
@@ -49,6 +49,8 @@ export class RuntimeArchiveStore {
49
49
  this.root = root;
50
50
  this.transport = transport;
51
51
  }
52
+ /** Distinguishes "no transport configured" from transient upload failures. */
53
+ get hasTransport() { return this.transport != null; }
52
54
  setErrorReporter(reporter) {
53
55
  this.errorReporter = reporter;
54
56
  }
@@ -56,7 +56,7 @@ export async function serveRuntime(options) {
56
56
  if (options.signal.aborted)
57
57
  return;
58
58
  if (outcome === "fatal")
59
- throw new Error("Runtime connection rejected / Runtime 连接被拒绝,请检查权限或升级 CLI");
59
+ throw new Error("Runtime connection rejected; check permissions or upgrade the CLI");
60
60
  if (readyAt && Date.now() - readyAt >= 60_000) {
61
61
  backoff = 500;
62
62
  attempt = 0;
@@ -139,10 +139,11 @@ async function connect(options) {
139
139
  }
140
140
  socket.send(data);
141
141
  };
142
- const stop = () => {
142
+ const closeTransport = () => socket.close();
143
+ const shutdown = () => {
143
144
  for (const execution of active.values())
144
145
  execution.controller.abort();
145
- socket.close();
146
+ closeTransport();
146
147
  };
147
148
  const sendNative = (event) => new Promise((resolve, reject) => {
148
149
  if (!nativeChannelReady || !connectionId) {
@@ -161,7 +162,7 @@ async function connect(options) {
161
162
  reject(error instanceof Error ? error : new Error(String(error)));
162
163
  }
163
164
  });
164
- options.signal.addEventListener("abort", stop, { once: true });
165
+ options.signal.addEventListener("abort", shutdown, { once: true });
165
166
  const heartbeat = setInterval(() => {
166
167
  const heartbeatAgeMs = Date.now() - lastHeartbeat;
167
168
  if (heartbeatAgeMs > 30_000) {
@@ -169,12 +170,7 @@ async function connect(options) {
169
170
  ageMs: heartbeatAgeMs,
170
171
  activeExecutions: active.size,
171
172
  }, { connectionId });
172
- for (const execution of active.values()) {
173
- log("error", "runtime.execution_heartbeat_timeout", {
174
- ageMs: heartbeatAgeMs,
175
- }, executionContext(connectionId, execution));
176
- }
177
- stop();
173
+ closeTransport();
178
174
  }
179
175
  else if (connectionId) {
180
176
  try {
@@ -184,7 +180,7 @@ async function connect(options) {
184
180
  log("warn", "runtime.heartbeat_send_failed", {
185
181
  error: serializeDiagnosticError(error),
186
182
  }, { connectionId });
187
- stop();
183
+ closeTransport();
188
184
  }
189
185
  void options.token().then((next) => {
190
186
  if (next !== currentToken) {
@@ -197,12 +193,12 @@ async function connect(options) {
197
193
  log("warn", "runtime.auth_refresh_send_failed", {
198
194
  error: serializeDiagnosticError(error),
199
195
  }, { connectionId });
200
- stop();
196
+ closeTransport();
201
197
  }
202
198
  }
203
199
  }).catch((error) => {
204
200
  log("warn", "runtime.auth_refresh_failed", { error: serializeDiagnosticError(error) }, { connectionId });
205
- stop();
201
+ closeTransport();
206
202
  });
207
203
  }
208
204
  }, 10_000);
@@ -227,12 +223,14 @@ async function connect(options) {
227
223
  }
228
224
  nativePending.clear();
229
225
  options.onDisconnected?.();
226
+ const executionMustStop = options.signal.aborted || fatal || conflict;
230
227
  for (const execution of active.values()) {
231
- log("error", "runtime.execution_transport_lost", {
228
+ log(executionMustStop ? "error" : "warn", executionMustStop ? "runtime.execution_transport_invalidated" : "runtime.execution_transport_detached", {
232
229
  code: event.code,
233
230
  reason: event.reason,
234
231
  }, executionContext(connectionId, execution));
235
- execution.controller.abort();
232
+ if (executionMustStop)
233
+ execution.controller.abort();
236
234
  }
237
235
  resolve();
238
236
  }, { once: true });
@@ -261,7 +259,7 @@ async function connect(options) {
261
259
  }
262
260
  catch (error) {
263
261
  log("error", "runtime.hello_failed", { error: serializeDiagnosticError(error) });
264
- stop();
262
+ closeTransport();
265
263
  }
266
264
  });
267
265
  socket.addEventListener("message", (event) => {
@@ -274,6 +272,8 @@ async function connect(options) {
274
272
  log("error", "runtime.protocol.invalid_json", { error: serializeDiagnosticError(error) }, { connectionId });
275
273
  throw error;
276
274
  }
275
+ // Any inbound frame proves the transport is live; heartbeat frames are one case.
276
+ lastHeartbeat = Date.now();
277
277
  if (raw.type === "runtime.ready") {
278
278
  const frame = runtimeReadySchema.parse(raw);
279
279
  if (connectionId === frame.connectionId)
@@ -303,7 +303,6 @@ async function connect(options) {
303
303
  if (!connectionId)
304
304
  throw new Error("Runtime handshake is incomplete");
305
305
  if (raw.type === "runtime.heartbeat") {
306
- lastHeartbeat = Date.now();
307
306
  return;
308
307
  }
309
308
  if (raw.type === "runtime.native.result") {
@@ -508,7 +507,17 @@ async function connect(options) {
508
507
  const emit = (value) => {
509
508
  if (value.type === "message.commit")
510
509
  durableEvents.push(value);
511
- send({ type: "runtime.event", requestId: frame.requestId, event: value });
510
+ // Streaming is best-effort. A detached transport must not cancel local model or tool work;
511
+ // durable commits and the final result are replayed after reconnect.
512
+ if (disconnected.signal.aborted || socket.readyState !== WebSocket.OPEN || socket.bufferedAmount > RUNTIME_MAX_FRAME_BYTES)
513
+ return;
514
+ try {
515
+ send({ type: "runtime.event", requestId: frame.requestId, event: value });
516
+ }
517
+ catch (error) {
518
+ if (!disconnected.signal.aborted && socket.readyState === WebSocket.OPEN)
519
+ throw error;
520
+ }
512
521
  };
513
522
  execution.promise = (async () => {
514
523
  try {
@@ -541,7 +550,10 @@ async function connect(options) {
541
550
  frame.input.context = await requestContext(controller.signal);
542
551
  }
543
552
  }
544
- await options.store.recordResult(execution.result.state, frame.requestId, [...durableEvents, execution.result.event]);
553
+ await options.store.recordResult(execution.result.state, frame.requestId, [...durableEvents, execution.result.event], execution.result.uncertainCleanup ? { uncertainCleanup: execution.result.uncertainCleanup } : undefined);
554
+ if (execution.result.uncertainCleanup) {
555
+ log("warn", "runtime.turn_cleanup_pending", { cleanup: execution.result.uncertainCleanup.error }, context);
556
+ }
545
557
  emit(execution.result.event);
546
558
  }
547
559
  catch (error) {
@@ -560,7 +572,7 @@ async function connect(options) {
560
572
  })();
561
573
  })().catch((error) => {
562
574
  log("error", "runtime.protocol_error", { error: serializeDiagnosticError(error) }, { connectionId });
563
- stop();
575
+ closeTransport();
564
576
  });
565
577
  });
566
578
  readyTimer = setTimeout(() => {
@@ -568,7 +580,7 @@ async function connect(options) {
568
580
  socket.close(4408, "Runtime handshake timed out");
569
581
  }, 15_000);
570
582
  if (options.signal.aborted)
571
- stop();
583
+ shutdown();
572
584
  try {
573
585
  await closed;
574
586
  await Promise.allSettled([...active.values()].map((entry) => entry.promise));
@@ -576,7 +588,7 @@ async function connect(options) {
576
588
  finally {
577
589
  clearTimeout(readyTimer);
578
590
  clearInterval(heartbeat);
579
- options.signal.removeEventListener("abort", stop);
591
+ options.signal.removeEventListener("abort", shutdown);
580
592
  }
581
593
  if (unauthorized && !options.signal.aborted)
582
594
  await options.token(true);