@neta-art/cohub-cli 8.0.0 → 8.0.1

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.
@@ -13,23 +13,23 @@ import { RuntimeSessionStore } from "../runtime/session-store.js";
13
13
  import { readRuntimeDiagnosticEvents, RuntimeDiagnosticReader, runtimeDiagnosticsDirectory, serializeDiagnosticError } from "../runtime/diagnostics.js";
14
14
  export { resolveLocalSpaceName, parseRuntimeHarnesses } from "../runtime/launch.js";
15
15
  const reportFailure = (cause) => {
16
- process.stderr.write(`Runtime failed / Runtime 失败: ${serializeDiagnosticError(cause).message}\n`);
16
+ process.stderr.write(`Runtime failed: ${serializeDiagnosticError(cause).message}\n`);
17
17
  process.exitCode = 1;
18
18
  };
19
19
  export function registerRuntime(program) {
20
- const runtime = program.command("runtime").description("Connect a local workspace / 连接本地工作区");
20
+ const runtime = program.command("runtime").description("Connect a local workspace");
21
21
  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 输出")
22
+ .description("Connect local Harnesses and files")
23
+ .option("-s, --space <id>", "Target Space")
24
+ .option("-n, --new", "Create a new Space")
25
+ .option("--name <name>", "New Space name")
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")
29
+ .option("--codex <path>", "Codex executable")
30
+ .option("-y, --yes", "Accept defaults and authorize local execution")
31
+ .option("--verbose", "Show diagnostic details")
32
+ .option("--json", "JSON output")
33
33
  .action(async (dir, options) => {
34
34
  try {
35
35
  await runtimeUp(program, dir, { ...options, json: jsonRequested(options) });
@@ -40,36 +40,36 @@ export function registerRuntime(program) {
40
40
  });
41
41
  for (const action of ["attach", "detach"])
42
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 输出")
43
+ .description(action === "attach" ? "Sync native Pi / Codex Turns" : "Pause native sync; retain all receipts")
44
+ .option("-s, --space <id>", "Target Space")
45
+ .option("--harness <name>", "Pi or Codex; repeatable", (value, previous) => [...previous, value], [])
46
+ .option("--pi <path>", "Pi executable for capability checks")
47
+ .option("--codex <path>", "Codex executable for capability checks")
48
+ .option("-y, --yes", "Authorize project conversation and native archive uploads")
49
+ .option("--json", "JSON output")
50
50
  .action(async (options) => {
51
51
  try {
52
52
  const spaceId = await resolveRuntimeTarget(program, options.space);
53
53
  const identity = currentIdentityKey();
54
54
  if (!identity)
55
- throw new Error("Sign in first / 请先登录");
55
+ throw new Error("Sign in first");
56
56
  const root = await canonicalRuntimeRoot(process.cwd());
57
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 绑定当前目录");
58
+ throw new Error("Bind this directory with runtime up --space first");
59
59
  const instance = await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId));
60
60
  if (action === "attach" && (!instance || instance.root !== root))
61
- throw new Error("Start this directory's Runtime first: cohub runtime up -d / 请先启动当前目录的 Runtime");
61
+ throw new Error("Start this directory's Runtime first: cohub runtime up -d");
62
62
  if (action === "attach" && !instance?.nativeSync)
63
- throw new Error("Restart the Runtime with this CLI before attaching / 请先使用新版 CLI 重启 Runtime,再接入原生客户端");
63
+ throw new Error("Restart the Runtime with this CLI before attaching");
64
64
  const harnesses = parseRuntimeHarnesses(options.harness.length ? options.harness : instance?.harnesses ?? ["pi", "codex"]);
65
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");
66
+ throw new Error("Enable these Harnesses with runtime up first");
67
67
  if (action === "attach" && !options.yes) {
68
68
  if (!process.stdin.isTTY)
69
- throw new Error("Use --yes to authorize native sync / 请使用 --yes 授权原生同步");
69
+ throw new Error("Use --yes to authorize native sync");
70
70
  const rl = createInterface({ input: process.stdin, output: process.stderr });
71
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] `);
72
+ const answer = await rl.question(`Install user-level ${harnesses.join(" / ")} integration and upload this project's opened conversations, tool output and raw archives to this Space? History may contain secrets. [y/N] `);
73
73
  if (!/^y(es)?$/i.test(answer.trim()))
74
74
  return;
75
75
  }
@@ -82,23 +82,23 @@ export function registerRuntime(program) {
82
82
  outJson(result);
83
83
  else
84
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");
85
+ ? `Native sync enabled. Reload Pi or restart Codex and review its hook trust prompt.\n${result.configPath}\n`
86
+ : "Native sync paused; all local records retained\n");
87
87
  }
88
88
  catch (cause) {
89
89
  reportFailure(cause);
90
90
  }
91
91
  });
92
- runtime.command("status").description("Local and server status / 本地与服务端状态")
93
- .option("-s, --space <id>", "Target Space / 目标 Space")
94
- .option("--json", "JSON output / JSON 输出")
92
+ runtime.command("status").description("Local and server status")
93
+ .option("-s, --space <id>", "Target Space")
94
+ .option("--json", "JSON output")
95
95
  .action(async (options) => {
96
96
  try {
97
97
  const spaceId = await resolveRuntimeTarget(program, options.space);
98
98
  const identity = currentIdentityKey();
99
99
  const local = identity ? await requestRuntimeInstance(runtimeInstanceDirectory(identity, spaceId)) : null;
100
100
  const space = createClient().space(spaceId);
101
- const store = new RuntimeSessionStore(spaceId, { projectionSource: space });
101
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: space, archiveTransport: null });
102
102
  const [remote, pendingLocalArchives, failedLocalArchives, nativeStores] = await Promise.all([
103
103
  space.getRuntime(undefined, { signal: AbortSignal.timeout(5000) }).then((value) => ({ value, error: null })).catch((error) => ({ value: null, error: serializeDiagnosticError(error).message })),
104
104
  store.archives.pendingCount(), store.archives.failedCaptureCount(),
@@ -112,26 +112,26 @@ export function registerRuntime(program) {
112
112
  if (local)
113
113
  printRuntimeSummary(local);
114
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`);
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" : "Offline"}\nArchives ${pendingLocalArchives} pending · ${failedLocalArchives} failed\n`);
117
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`);
118
+ process.stdout.write(`Native chats ${nativeSessions.length} · ${nativeSessions.reduce((sum, session) => sum + session.pendingTurns, 0)} Turns pending · ${nativeSessions.reduce((sum, session) => sum + session.pendingArchives, 0)} archives pending\n`);
119
119
  }
120
120
  }
121
121
  catch (cause) {
122
122
  reportFailure(cause);
123
123
  }
124
124
  });
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 输出")
125
+ runtime.command("down").description("Stop this local Runtime; retain all data")
126
+ .option("-s, --space <id>", "Target Space")
127
+ .option("-y, --yes", "Stop even with unconfirmed executions")
128
+ .option("--json", "JSON output")
129
129
  .action(async (options) => {
130
130
  try {
131
131
  const spaceId = await resolveRuntimeTarget(program, options.space);
132
132
  const identity = currentIdentityKey();
133
133
  if (!identity)
134
- throw new Error("Sign in to the Runtime account / 请登录 Runtime 所属账号");
134
+ throw new Error("Sign in to the Runtime account");
135
135
  const directory = runtimeInstanceDirectory(identity, spaceId);
136
136
  const local = await requestRuntimeInstance(directory, "stop", Boolean(options.yes));
137
137
  const until = Date.now() + 15_000;
@@ -146,33 +146,33 @@ export function registerRuntime(program) {
146
146
  } // An unreachable control socket does not prove the process stopped.
147
147
  }
148
148
  if (running)
149
- throw new Error("Runtime is still stopping; inspect logs / Runtime 仍在停止,请检查日志");
149
+ throw new Error("Runtime is still stopping; inspect logs");
150
150
  if (jsonRequested(options))
151
151
  outJson({ spaceId, stopped: true });
152
152
  else
153
- process.stdout.write("Runtime stopped; data retained / Runtime 已停止,数据已保留\n");
153
+ process.stdout.write("Runtime stopped; data retained\n");
154
154
  }
155
155
  catch (cause) {
156
156
  reportFailure(cause);
157
157
  }
158
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 / 原始诊断事件")
159
+ runtime.command("logs").description("Read local Runtime diagnostics")
160
+ .option("-s, --space <id>", "Target 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
165
  .action(async (options) => {
166
166
  const controller = new AbortController();
167
167
  const stop = () => controller.abort();
168
168
  try {
169
169
  const spaceId = await resolveRuntimeTarget(program, options.space);
170
- const store = new RuntimeSessionStore(spaceId, { projectionSource: createClient().space(spaceId) });
170
+ const store = new RuntimeSessionStore(spaceId, { projectionSource: createClient().space(spaceId), archiveTransport: null });
171
171
  const limit = Number(options.limit);
172
172
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
173
- throw new Error("Use a limit from 1 to 10000 / 数量范围为 1 到 10000");
173
+ throw new Error("Use a limit from 1 to 10000");
174
174
  if (!diagnosticLevels.includes(options.level))
175
- throw new Error("Use debug, info, warn or error / 请使用有效日志级别");
175
+ throw new Error("Use debug, info, warn or error");
176
176
  const asJson = jsonRequested(options);
177
177
  const reader = new RuntimeDiagnosticReader(store.root);
178
178
  process.once("SIGINT", stop);
@@ -187,7 +187,7 @@ export function registerRuntime(program) {
187
187
  process.stdout.write(asJson ? `${JSON.stringify(event)}\n` : formatDiagnostic(event, true));
188
188
  if (!options.follow) {
189
189
  if (!asJson && !events.length)
190
- process.stdout.write("No matching diagnostics / 未找到匹配日志\n");
190
+ process.stdout.write("No matching diagnostics\n");
191
191
  break;
192
192
  }
193
193
  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.54.1";
2
2
  export declare class SandboxdDownloadError extends Error {
3
3
  name: string;
4
4
  }
@@ -21,7 +21,13 @@ 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. No new capability is required, so this
29
+ // pin is a diagnostics upgrade only.
30
+ export const SANDBOXD_VERSION = "v2.54.1";
25
31
  const BINARY_NAME = "cohub-sandboxd";
26
32
  // Public CDN prefix hosting the release archives (the repo is private, so the
27
33
  // 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);
@@ -14,6 +14,10 @@ export type HarnessResult = {
14
14
  event: Extract<RuntimeExecutionEvent, {
15
15
  type: "turn.end";
16
16
  }>;
17
+ uncertainCleanup?: {
18
+ processGroupId: number;
19
+ error: string;
20
+ };
17
21
  };
18
22
  export declare function piContent(value: unknown): ContentBlock[];
19
23
  export declare function promptText(content: ContentBlock[]): string;
@@ -2,6 +2,7 @@ import { access, stat } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { delimiter, join, resolve, win32 } from "node:path";
4
4
  import { JsonRpcProcess, record } from "./json-rpc.js";
5
+ import { ProcessCleanupUncertainError } from "./process-group.js";
5
6
  import { codexModelCatalog } from "./model-catalog.js";
6
7
  import { codexTokenTotals, codexUsage, subtractCodexTokens } from "./codex-usage.js";
7
8
  import { downloadPublicImage } from "../safe-remote-image.js";
@@ -85,6 +86,16 @@ async function finishHarnessTurn(store, state, message, resume, turnId, diagnost
85
86
  const archive = await store.archive(state, turnId, diagnosticContext).catch(() => null);
86
87
  return { state, event: { type: "turn.end", message, resume, archive } };
87
88
  }
89
+ /** Close the RPC process. An unconfirmable cleanup degrades the result to uncertain, never masks it. */
90
+ async function closeHarness(rpc, executionError) {
91
+ return rpc.close().then(() => undefined, (cleanupError) => {
92
+ const processGroupId = rpc.processGroupId;
93
+ if (!(cleanupError instanceof ProcessCleanupUncertainError) || processGroupId == null)
94
+ throw cleanupError;
95
+ const detail = executionError ? `; execution error: ${executionError}` : "";
96
+ return { processGroupId, error: `${cleanupError.message}${detail}` };
97
+ });
98
+ }
88
99
  export async function discoverHarnesses(harnesses, options, cwd) {
89
100
  const models = [];
90
101
  await Promise.all(harnesses.map(async (harness) => {
@@ -133,6 +144,7 @@ export async function executePi(input, options, cwd, store, emit, signal, diagno
133
144
  let currentContent = [];
134
145
  let last = { ordinal: 0, content: [] };
135
146
  const abortEscalation = createAbortEscalation(rpc, signal, () => { void rpc.request("abort").catch(() => undefined); });
147
+ let uncertainCleanup;
136
148
  const stopFailureLogging = diagnostics
137
149
  ? rpc.onFailure((error) => diagnostics.log("error", "harness.rpc_process_failed", { error: serializeDiagnosticError(error) }, { ...diagnosticContext, component: "harness" }))
138
150
  : () => undefined;
@@ -235,17 +247,17 @@ export async function executePi(input, options, cwd, store, emit, signal, diagno
235
247
  abortEscalation.clear();
236
248
  stopFailureLogging();
237
249
  stopTimeoutLogging();
238
- await rpc.close();
250
+ uncertainCleanup = await closeHarness(rpc, last.errorMessage);
239
251
  }
240
252
  if (signal.aborted)
241
253
  last = { ...last, stopReason: "aborted" };
242
- return finishHarnessTurn(store, state, last, resume, input.turnId, {
243
- component: "harness",
244
- sessionId: input.sessionId,
245
- turnId: input.turnId,
246
- harness: "pi",
247
- traceContext: input.traceContext,
248
- });
254
+ return { ...await finishHarnessTurn(store, state, last, resume, input.turnId, {
255
+ component: "harness",
256
+ sessionId: input.sessionId,
257
+ turnId: input.turnId,
258
+ harness: "pi",
259
+ traceContext: input.traceContext,
260
+ }), ...(uncertainCleanup ? { uncertainCleanup } : {}) };
249
261
  }
250
262
  export function codexItemContent(item) {
251
263
  if (item.type === "agentMessage" || item.type === "plan")
@@ -295,6 +307,7 @@ export async function executeCodex(input, options, cwd, store, emit, signal, dia
295
307
  if (nativeTurnId)
296
308
  void rpc.request("turn/interrupt", { threadId: state.nativeSessionId, turnId: nativeTurnId }).catch(() => undefined);
297
309
  });
310
+ let uncertainCleanup;
298
311
  try {
299
312
  await initializeCodex(rpc);
300
313
  const threadOptions = {
@@ -451,17 +464,17 @@ export async function executeCodex(input, options, cwd, store, emit, signal, dia
451
464
  abortEscalation.clear();
452
465
  stopFailureLogging();
453
466
  stopTimeoutLogging();
454
- await rpc.close();
467
+ uncertainCleanup = await closeHarness(rpc, final.errorMessage);
455
468
  }
456
469
  if (signal.aborted)
457
470
  final = { ...final, stopReason: "aborted" };
458
471
  if (usage)
459
472
  final = { ...final, usage };
460
- return finishHarnessTurn(store, state, final, resume, input.turnId, {
461
- component: "harness",
462
- sessionId: input.sessionId,
463
- turnId: input.turnId,
464
- harness: "codex",
465
- traceContext: input.traceContext,
466
- });
473
+ return { ...await finishHarnessTurn(store, state, final, resume, input.turnId, {
474
+ component: "harness",
475
+ sessionId: input.sessionId,
476
+ turnId: input.turnId,
477
+ harness: "codex",
478
+ traceContext: input.traceContext,
479
+ }), ...(uncertainCleanup ? { uncertainCleanup } : {}) };
467
480
  }
@@ -23,7 +23,7 @@ async function readRecord(directory) {
23
23
  try {
24
24
  const value = JSON.parse(await readFile(join(directory, "owner.json"), "utf8"));
25
25
  if (!Number.isSafeInteger(value.pid) || typeof value.nonce !== "string" || typeof value.socket !== "string")
26
- throw new Error("Invalid Runtime instance record / Runtime 实例记录无效");
26
+ throw new Error("Invalid Runtime instance record");
27
27
  return value;
28
28
  }
29
29
  catch (error) {
@@ -35,7 +35,7 @@ async function readRecord(directory) {
35
35
  function request(record, action, force = false) {
36
36
  return new Promise((resolve, reject) => {
37
37
  const socket = createConnection(record.socket);
38
- const timer = setTimeout(() => { socket.destroy(); reject(new Error("Runtime control timed out / Runtime 控制连接超时")); }, 3000);
38
+ const timer = setTimeout(() => { socket.destroy(); reject(new Error("Runtime control timed out")); }, 3000);
39
39
  let buffer = "";
40
40
  let settled = false;
41
41
  const finish = (error, value) => {
@@ -51,7 +51,7 @@ function request(record, action, force = false) {
51
51
  };
52
52
  socket.on("connect", () => socket.write(`${JSON.stringify({ nonce: record.nonce, action, force })}\n`));
53
53
  socket.on("error", (error) => finish(error));
54
- socket.on("close", () => finish(new Error("Runtime control closed / Runtime 控制连接已关闭")));
54
+ socket.on("close", () => finish(new Error("Runtime control closed")));
55
55
  socket.on("data", (chunk) => {
56
56
  buffer += chunk.toString();
57
57
  if (buffer.length > 64 * 1024) {
@@ -65,7 +65,7 @@ function request(record, action, force = false) {
65
65
  if (response.error)
66
66
  finish(new Error(response.error));
67
67
  else if (response.nonce !== record.nonce || response.status?.pid !== record.pid)
68
- finish(new Error("Runtime identity changed / Runtime 身份已变化"));
68
+ finish(new Error("Runtime identity changed"));
69
69
  else
70
70
  finish(undefined, response.status);
71
71
  }
@@ -95,7 +95,7 @@ export async function ownRuntimeInstance(directory, status, stop) {
95
95
  await mkdir(directory, { recursive: true, mode: 0o700 });
96
96
  return withRuntimeSpaceBindingsLock(async () => {
97
97
  if (await requestRuntimeInstance(directory))
98
- throw new Error("Runtime already running; use status / Runtime 已在运行,请使用 status");
98
+ throw new Error("Runtime already running; use status");
99
99
  const socketPath = process.platform === "win32"
100
100
  ? `\\\\.\\pipe\\cohub-${createHash("sha256").update(directory).digest("hex").slice(0, 24)}`
101
101
  : join(directory, "control.sock");
@@ -17,6 +17,8 @@ export declare function harnessEnvironment(): NodeJS.ProcessEnv;
17
17
  export declare class JsonRpcProcess {
18
18
  private mode;
19
19
  private child;
20
+ /** Process-group id of the harness process, for deferred cleanup confirmation. */
21
+ get processGroupId(): number | null;
20
22
  private pending;
21
23
  private listeners;
22
24
  private failureListeners;
@@ -50,6 +50,8 @@ export function harnessEnvironment() {
50
50
  export class JsonRpcProcess {
51
51
  mode;
52
52
  child;
53
+ /** Process-group id of the harness process, for deferred cleanup confirmation. */
54
+ get processGroupId() { return this.child.pid ?? null; }
53
55
  pending = new Map();
54
56
  listeners = new Set();
55
57
  failureListeners = new Set();