@neta-art/cohub-cli 7.1.2 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +40 -2
  2. package/dist/auth.js +38 -5
  3. package/dist/client.js +5 -2
  4. package/dist/commands/runtime.d.ts +1 -2
  5. package/dist/commands/runtime.js +178 -302
  6. package/dist/commands/sandboxd-binary.d.ts +1 -1
  7. package/dist/commands/sandboxd-binary.js +7 -5
  8. package/dist/runtime/archive-store.d.ts +2 -0
  9. package/dist/runtime/archive-store.js +20 -6
  10. package/dist/runtime/connection.d.ts +4 -2
  11. package/dist/runtime/connection.js +80 -23
  12. package/dist/runtime/diagnostics.d.ts +3 -0
  13. package/dist/runtime/diagnostics.js +3 -0
  14. package/dist/runtime/harness.d.ts +3 -0
  15. package/dist/runtime/harness.js +34 -1
  16. package/dist/runtime/instance.d.ts +5 -0
  17. package/dist/runtime/instance.js +159 -0
  18. package/dist/runtime/launch.d.ts +20 -0
  19. package/dist/runtime/launch.js +176 -0
  20. package/dist/runtime/native-codex-hook.d.ts +1 -0
  21. package/dist/runtime/native-codex-hook.js +28 -0
  22. package/dist/runtime/native-install.d.ts +21 -0
  23. package/dist/runtime/native-install.js +130 -0
  24. package/dist/runtime/native-ipc.d.ts +26 -0
  25. package/dist/runtime/native-ipc.js +101 -0
  26. package/dist/runtime/native-pi-extension.d.ts +20 -0
  27. package/dist/runtime/native-pi-extension.js +47 -0
  28. package/dist/runtime/native-sync-store.d.ts +97 -0
  29. package/dist/runtime/native-sync-store.js +365 -0
  30. package/dist/runtime/native-sync.d.ts +25 -0
  31. package/dist/runtime/native-sync.js +128 -0
  32. package/dist/runtime/native-transcript.d.ts +27 -0
  33. package/dist/runtime/native-transcript.js +281 -0
  34. package/dist/runtime/presentation.d.ts +21 -0
  35. package/dist/runtime/presentation.js +76 -0
  36. package/dist/runtime/session-store.d.ts +2 -0
  37. package/dist/runtime/session-store.js +40 -5
  38. package/dist/runtime/space-binding.d.ts +3 -0
  39. package/dist/runtime/space-binding.js +43 -6
  40. package/dist/runtime/supervisor.d.ts +16 -0
  41. package/dist/runtime/supervisor.js +277 -0
  42. package/dist/runtime/worker.d.ts +1 -0
  43. package/dist/runtime/worker.js +20 -0
  44. package/package.json +3 -2
@@ -0,0 +1,281 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { piContent } from "./harness.js";
4
+ import { codexTokenTotals, codexUsage } from "./codex-usage.js";
5
+ import { record } from "./json-rpc.js";
6
+ const text = (value) => typeof value === "string" ? value : "";
7
+ const list = (value) => Array.isArray(value) ? value : [];
8
+ const iso = (value) => {
9
+ const date = new Date(typeof value === "number" || typeof value === "string" ? value : 0);
10
+ if (!Number.isFinite(date.getTime()))
11
+ throw new Error("Invalid native timestamp / 原生时间无效");
12
+ return date.toISOString();
13
+ };
14
+ /** Partial trailing records are retried, never parsed or acknowledged as complete. */
15
+ export async function readNativeTranscript(path, harness, options = {}) {
16
+ const lines = [];
17
+ const prefixes = new Map();
18
+ let fragments = [], pendingBytes = 0, offset = 0;
19
+ const checksum = createHash("sha256");
20
+ const append = (bytes) => {
21
+ if (!bytes.length)
22
+ return;
23
+ fragments.push(bytes);
24
+ pendingBytes += bytes.length;
25
+ checksum.update(bytes);
26
+ if (pendingBytes > 32 * 1024 * 1024)
27
+ throw new Error("Native record is too large / 原生记录过大");
28
+ if (offset + pendingBytes > 128 * 1024 * 1024)
29
+ throw new Error("Native transcript exceeds the capture limit; original retained / 原生记录超出采集上限,原件已保留");
30
+ };
31
+ for await (const chunk of createReadStream(path)) {
32
+ let start = 0;
33
+ for (let end = chunk.indexOf(10); end >= 0; end = chunk.indexOf(10, start)) {
34
+ append(chunk.subarray(start, end + 1));
35
+ // Concatenate once per record, not once per read chunk (large base64 images stay linear).
36
+ const bytes = fragments.length === 1 ? fragments[0] : Buffer.concat(fragments, pendingBytes);
37
+ const line = bytes.subarray(0, bytes.length - 1);
38
+ const startBytes = offset;
39
+ offset += pendingBytes;
40
+ fragments = [];
41
+ pendingBytes = 0;
42
+ const sha256 = checksum.copy().digest("hex");
43
+ prefixes.set(offset, sha256);
44
+ if (line.length) {
45
+ let value;
46
+ try {
47
+ value = record(JSON.parse(line.toString("utf8")));
48
+ }
49
+ catch {
50
+ throw new Error("Invalid native JSON record; original retained / 原生 JSON 记录无效,原件已保留");
51
+ }
52
+ lines.push({ value, startBytes, endBytes: offset, sha256 });
53
+ }
54
+ start = end + 1;
55
+ }
56
+ append(chunk.subarray(start));
57
+ }
58
+ if (!lines.length)
59
+ throw new Error("Native transcript is empty / 原生记录为空");
60
+ return { ...(harness === "pi" ? parsePiTranscript(lines, options) : parseCodexTranscript(lines)), prefixes };
61
+ }
62
+ function parsePiTranscript(lines, options) {
63
+ const header = lines[0]?.value ?? {};
64
+ if (header.type !== "session" || !text(header.id))
65
+ throw new Error("Invalid Pi session / Pi 会话无效");
66
+ const entries = new Map(lines.slice(1).filter((line) => text(line.value.id)).map((line) => [text(line.value.id), line]));
67
+ const branch = [];
68
+ let leaf = options.leafId ?? (lines.length > 1 ? text(lines.at(-1)?.value.id) : "");
69
+ const visited = new Set();
70
+ while (leaf) {
71
+ if (visited.has(leaf))
72
+ throw new Error("Cyclic Pi history / Pi 历史存在循环");
73
+ visited.add(leaf);
74
+ const entry = entries.get(leaf);
75
+ if (!entry)
76
+ throw new Error("Pi parent history is missing / Pi 父历史缺失");
77
+ branch.push(entry);
78
+ leaf = text(entry.value.parentId);
79
+ }
80
+ branch.reverse();
81
+ const turns = [];
82
+ let cloudSessionId = text(record(header.cohub).sessionId) || text(record(header.affinity).sessionId);
83
+ let current = null;
84
+ let messages = [];
85
+ let completedAt = iso(header.timestamp);
86
+ const finish = (settled) => {
87
+ if (!current)
88
+ return;
89
+ const last = messages.at(-1);
90
+ if (settled)
91
+ current.result = { messages, completedAt, status: !last || ["aborted", "pending", "toolUse"].includes(last.stopReason ?? "") ? "interrupted" : last?.errorMessage || last?.stopReason === "error" ? "failed" : "completed" };
92
+ turns.push(current);
93
+ };
94
+ for (const line of branch) {
95
+ const entry = line.value;
96
+ if (entry.type !== "message") {
97
+ if (current) {
98
+ current.endBytes = line.endBytes;
99
+ current.sha256 = line.sha256;
100
+ current.boundaries[line.endBytes] = line.sha256;
101
+ }
102
+ continue;
103
+ }
104
+ const message = record(entry.message);
105
+ if (!["user", "assistant", "toolResult"].includes(text(message.role))) {
106
+ // Native-only UI / shell records remain in raw archives; they do not rewrite a settled agent Turn.
107
+ if (current) {
108
+ current.endBytes = line.endBytes;
109
+ current.sha256 = line.sha256;
110
+ current.boundaries[line.endBytes] = line.sha256;
111
+ }
112
+ continue;
113
+ }
114
+ const timestamp = iso(entry.timestamp ?? message.timestamp);
115
+ if (message.role === "user") {
116
+ finish(true);
117
+ completedAt = timestamp;
118
+ const cloudTurnId = text(record(message.meta).turnId);
119
+ if (cloudTurnId && !text(record(header.cohub).sessionId) && !text(record(header.affinity).sessionId))
120
+ cloudSessionId = text(record(message.meta).sourceSessionId) || cloudSessionId;
121
+ current = { key: text(entry.id), parentKey: turns.at(-1)?.key ?? null, ...(cloudTurnId ? { cloudTurnId } : {}), userContent: typeof message.content === "string" ? [{ type: "text", text: message.content }] : piContent(message.content), messages: [], startedAt: completedAt, startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null };
122
+ messages = current.messages;
123
+ }
124
+ else if (current && message.role === "assistant") {
125
+ messages.push({ content: piContent(message.content), provider: text(message.provider) || null, model: text(message.model) || null,
126
+ usage: record(message.usage), stopReason: text(message.stopReason) || null, errorMessage: text(message.errorMessage) || null });
127
+ }
128
+ else if (current && message.role === "toolResult") {
129
+ const assistant = messages.at(-1);
130
+ if (!assistant)
131
+ throw new Error("Pi tool result has no assistant Turn / Pi 工具结果缺少所属 Turn");
132
+ assistant.content.push({ type: "tool_result", tool_use_id: text(message.toolCallId), content: typeof message.content === "string" ? message.content : piContent(message.content), is_error: Boolean(message.isError) });
133
+ }
134
+ if (current) {
135
+ current.endBytes = line.endBytes;
136
+ current.contentEndBytes = line.endBytes;
137
+ current.sha256 = line.sha256;
138
+ current.boundaries[line.endBytes] = line.sha256;
139
+ }
140
+ completedAt = timestamp;
141
+ }
142
+ finish(Boolean(options.settled));
143
+ return { nativeSessionId: text(header.id), cwd: text(header.cwd), cloudSessionId: cloudSessionId || undefined, turns };
144
+ }
145
+ function codexContent(value) {
146
+ return list(value).flatMap((item) => {
147
+ const block = record(item);
148
+ if (["input_text", "output_text", "text"].includes(text(block.type)))
149
+ return [{ type: "text", text: text(block.text) }];
150
+ if (block.type === "input_image" && text(block.image_url)) {
151
+ const data = /^data:([^;]+);base64,(.*)$/s.exec(text(block.image_url));
152
+ return [{ type: "image", source: data ? { type: "base64", media_type: data[1] ?? "image/png", data: data[2] ?? "" } : { type: "url", url: text(block.image_url) } }];
153
+ }
154
+ return [];
155
+ });
156
+ }
157
+ function parseCodexTranscript(lines) {
158
+ const header = lines[0]?.value ?? {};
159
+ const metadata = record(header.payload);
160
+ if (header.type !== "session_meta" || !text(metadata.id))
161
+ throw new Error("Invalid Codex session / Codex 会话无效");
162
+ if (metadata.history_base || metadata.fork_source)
163
+ throw new Error("Codex history references another rollout; retain the original and materialize its full history first / Codex 历史引用其他记录,请保留原件并先导出完整历史");
164
+ const turns = [];
165
+ let current = null;
166
+ let messages = [];
167
+ let model = null;
168
+ let cloudSessionId = text(record(metadata.cohub).sessionId);
169
+ let provider = text(metadata.model_provider) || null;
170
+ let userFromResponse = false;
171
+ const usageByTurn = new Map();
172
+ const assistant = () => {
173
+ let last = messages.at(-1);
174
+ if (!last) {
175
+ last = { content: [], model, provider };
176
+ messages.push(last);
177
+ }
178
+ return last;
179
+ };
180
+ for (const line of lines.slice(1)) {
181
+ const entry = line.value, payload = record(entry.payload);
182
+ if (entry.type === "turn_context") {
183
+ model = text(payload.model) || model;
184
+ provider = text(payload.model_provider) || provider;
185
+ }
186
+ if (entry.type === "event_msg" && ["turn_started", "task_started"].includes(text(payload.type))) {
187
+ if (current)
188
+ turns.push(current);
189
+ current = { key: text(payload.turn_id), parentKey: turns.at(-1)?.key ?? null, userContent: [], messages: [], startedAt: iso(entry.timestamp), startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null };
190
+ if (!current.key)
191
+ throw new Error("Codex Turn identity is missing / Codex Turn 身份缺失");
192
+ messages = current.messages;
193
+ userFromResponse = false;
194
+ }
195
+ if (entry.type === "token_usage_record") {
196
+ const usage = record(payload.turn_token_usage);
197
+ if (typeof usage.total_tokens === "number")
198
+ usageByTurn.set(text(payload.turn_id), codexUsage(codexTokenTotals({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cachedInputTokens: usage.cached_input_tokens, cacheWriteInputTokens: usage.cache_write_input_tokens, totalTokens: usage.total_tokens })));
199
+ }
200
+ if (!current) {
201
+ const previous = turns.at(-1);
202
+ if (previous) {
203
+ previous.endBytes = line.endBytes;
204
+ previous.sha256 = line.sha256;
205
+ previous.boundaries[line.endBytes] = line.sha256;
206
+ }
207
+ continue;
208
+ }
209
+ current.endBytes = line.endBytes;
210
+ current.sha256 = line.sha256;
211
+ current.boundaries[line.endBytes] = line.sha256;
212
+ current.contentEndBytes = line.endBytes;
213
+ if (entry.type === "response_item") {
214
+ const cohub = record(record(entry.metadata).cohub);
215
+ if (typeof cohub.turnId === "string") {
216
+ current.cloudTurnId = cohub.turnId;
217
+ if (!text(record(metadata.cohub).sessionId))
218
+ cloudSessionId = text(cohub.sessionId) || cloudSessionId;
219
+ }
220
+ if (payload.type === "message" && payload.role === "user") {
221
+ if (!userFromResponse)
222
+ current.userContent = [];
223
+ current.userContent.push(...codexContent(payload.content));
224
+ userFromResponse = true;
225
+ }
226
+ else if (payload.type === "message" && payload.role === "assistant") {
227
+ const content = codexContent(payload.content);
228
+ if (!messages.length || messages.at(-1)?.content.length)
229
+ messages.push({ content, model, provider });
230
+ else
231
+ assistant().content.push(...content);
232
+ }
233
+ else if (payload.type === "reasoning") {
234
+ const thinking = list(payload.summary).map((item) => text(record(item).text)).join("\n");
235
+ if (thinking)
236
+ assistant().content.push({ type: "thinking", thinking });
237
+ }
238
+ else if (["function_call", "custom_tool_call"].includes(text(payload.type))) {
239
+ let input;
240
+ try {
241
+ input = payload.type === "custom_tool_call" ? { input: payload.input } : record(JSON.parse(text(payload.arguments)));
242
+ }
243
+ catch {
244
+ input = { raw: payload.arguments };
245
+ }
246
+ assistant().content.push({ type: "tool_use", id: text(payload.call_id), name: text(payload.name), input });
247
+ }
248
+ else if (["function_call_output", "custom_tool_call_output"].includes(text(payload.type))) {
249
+ assistant().content.push({ type: "tool_result", tool_use_id: text(payload.call_id), content: typeof payload.output === "string" ? payload.output : JSON.stringify(payload.output ?? null) });
250
+ }
251
+ }
252
+ if (entry.type === "event_msg" && payload.type === "user_message" && !userFromResponse) {
253
+ current.userContent = [{ type: "text", text: text(payload.message) }];
254
+ userFromResponse = true;
255
+ }
256
+ if (entry.type === "event_msg" && ["turn_complete", "task_complete", "turn_aborted"].includes(text(payload.type))) {
257
+ if (payload.turn_id && payload.turn_id !== current.key)
258
+ throw new Error("Codex Turn boundary mismatch / Codex Turn 边界不匹配");
259
+ if (!messages.length && text(payload.last_agent_message))
260
+ messages.push({ content: [{ type: "text", text: text(payload.last_agent_message) }], model, provider });
261
+ const errorMessage = text(record(payload.error).message);
262
+ const status = payload.type === "turn_aborted" ? "interrupted" : errorMessage ? "failed" : "completed";
263
+ const final = assistant();
264
+ final.stopReason = status === "interrupted" ? "aborted" : status === "failed" ? "error" : "stop";
265
+ if (errorMessage)
266
+ final.errorMessage = errorMessage;
267
+ current.result = { messages, completedAt: iso(entry.timestamp), status };
268
+ turns.push(current);
269
+ current = null;
270
+ messages = [];
271
+ }
272
+ }
273
+ if (current && userFromResponse)
274
+ turns.push(current);
275
+ for (const turn of turns) {
276
+ const last = turn.result?.messages.at(-1);
277
+ if (last && usageByTurn.has(turn.key))
278
+ last.usage = usageByTurn.get(turn.key);
279
+ }
280
+ return { nativeSessionId: text(metadata.id), cwd: text(metadata.cwd), cloudSessionId: cloudSessionId || undefined, turns };
281
+ }
@@ -0,0 +1,21 @@
1
+ import type { RuntimeDiagnostic, RuntimeDiagnosticLevel } from "./diagnostics.js";
2
+ export declare const diagnosticLevels: RuntimeDiagnosticLevel[];
3
+ export declare const atLeastLevel: (level: RuntimeDiagnosticLevel, minimum: RuntimeDiagnosticLevel) => boolean;
4
+ export declare const runtimeWebUrl: (spaceId: string) => string;
5
+ export declare function formatDiagnostic(event: RuntimeDiagnostic, verbose?: boolean): string;
6
+ /** File logs retain every event; repeated terminal warnings are coalesced. */
7
+ export declare function createDiagnosticConsole(verbose?: boolean, write?: (line: string) => boolean): (event: RuntimeDiagnostic) => void;
8
+ export type RuntimeSummary = {
9
+ spaceId: string;
10
+ runtimeId: string;
11
+ root: string;
12
+ harnesses: string[];
13
+ pid: number;
14
+ state: "starting" | "ready" | "reconnecting" | "attention" | "stopping";
15
+ harnessConnected: boolean;
16
+ workspaceConnected: boolean;
17
+ diagnosticsPath: string;
18
+ background: boolean;
19
+ nativeSync?: boolean;
20
+ };
21
+ export declare function printRuntimeSummary(summary: RuntimeSummary, json?: boolean, reused?: boolean): void;
@@ -0,0 +1,76 @@
1
+ import { resolveCohubEnvironment } from "@neta-art/cohub";
2
+ export const diagnosticLevels = ["debug", "info", "warn", "error"];
3
+ export const atLeastLevel = (level, minimum) => diagnosticLevels.indexOf(level) >= diagnosticLevels.indexOf(minimum);
4
+ export const runtimeWebUrl = (spaceId) => `https://${resolveCohubEnvironment() === "prod" ? "" : "dev."}cohub.live/spaces/${spaceId}`;
5
+ const messages = {
6
+ "runtime.ready": "Harness connected / Harness 已连接",
7
+ "runtime.available": "Runtime ready / Runtime 已就绪",
8
+ "runtime.websocket.closed": "Connection lost; reconnecting / 连接中断,正在重连",
9
+ "runtime.heartbeat_timeout": "Connection timed out; reconnecting / 连接超时,正在重连",
10
+ "runtime.auth_token_failed": "Cannot obtain credentials; retrying / 暂时无法获取凭证,正在重试",
11
+ "runtime.auth_required": "Sign in with cohub auth login / 请运行 cohub auth login 登录",
12
+ "runtime.stopped": "Runtime stopped / Runtime 已停止",
13
+ "runtime.failed": "Runtime needs attention / Runtime 需要处理",
14
+ "runtime.turn_failed": "Turn failed; local files retained / 执行失败,本地文件已保留",
15
+ "runtime.connection_failed": "Connection attempt failed; retrying / 连接失败,将继续重试",
16
+ "runtime.execution_transport_lost": "Execution disconnected; outcome needs reconciliation / 执行连接中断,结果待确认",
17
+ "archive.upload_pending": "Archive upload pending; local data retained / 归档待上传,本地数据已保留",
18
+ "native.sync_pending": "Native sync pending; local records retained / 原生同步待处理,本地记录已保留",
19
+ "archive.capture_pending": "Archive capture pending / 归档待处理",
20
+ "archive.capture_unavailable": "Archive unavailable; original receipt retained / 归档不可用,原始回执已保留",
21
+ "archive.restore_failed": "Native restore unavailable; using saved history / 原生恢复不可用,使用已保存历史",
22
+ "sandboxd.process_exit": "File bridge stopped; restarting / 文件桥接已退出,正在重启",
23
+ "sandboxd.download": "Preparing file bridge / 正在准备文件桥接",
24
+ "sandboxd.connected": "File bridge connected / 文件桥接已连接",
25
+ "sandboxd.disconnected": "File bridge disconnected; reconnecting / 文件桥接已断开,正在重连",
26
+ };
27
+ export function formatDiagnostic(event, verbose = false) {
28
+ const text = messages[event.event] ?? (typeof event.data?.message === "string" ? event.data.message : event.event);
29
+ const detail = event.error?.message ? ` — ${event.error.message}` : "";
30
+ const context = verbose ? ` ${JSON.stringify({ ...event.data, runtimeId: event.runtimeId, sessionId: event.sessionId, turnId: event.turnId })}` : "";
31
+ return `${event.timestamp.slice(11, 19)} ${event.level.toUpperCase().padEnd(5)} ${text}${detail}${context}\n`;
32
+ }
33
+ /** File logs retain every event; repeated terminal warnings are coalesced. */
34
+ export function createDiagnosticConsole(verbose = false, write = (line) => process.stderr.write(line)) {
35
+ const last = new Map();
36
+ return (event) => {
37
+ if (!verbose && (event.level === "debug" || !atLeastLevel(event.level, "warn") && !messages[event.event]))
38
+ return;
39
+ const key = `${event.component}:${event.event}:${event.level}:${event.error?.message ?? event.data?.message ?? ""}`;
40
+ const previous = last.get(key);
41
+ const now = Date.now();
42
+ if (!verbose && previous && now - previous.at < 30_000 && atLeastLevel(event.level, "warn")) {
43
+ previous.suppressed++;
44
+ return;
45
+ }
46
+ if (last.size >= 256)
47
+ last.delete(last.keys().next().value ?? "");
48
+ last.set(key, { at: now, suppressed: 0 });
49
+ const repeated = previous?.suppressed ? ` (+${previous.suppressed} repeated / 重复)` : "";
50
+ write(`${formatDiagnostic(event, verbose).trimEnd()}${repeated}\n`);
51
+ };
52
+ }
53
+ export function printRuntimeSummary(summary, json = false, reused = false) {
54
+ const value = { ...summary, url: runtimeWebUrl(summary.spaceId), reused };
55
+ if (json) {
56
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
57
+ return;
58
+ }
59
+ const label = summary.state === "ready" ? "Runtime ready / Runtime 已就绪" : `Runtime ${summary.state} / Runtime 尚未就绪`;
60
+ process.stdout.write(`\n${label}${reused ? " · reused / 已复用" : ""}\n\n`);
61
+ const rows = [
62
+ ["Space / 空间", summary.spaceId],
63
+ ["URL / 链接", value.url],
64
+ ["Directory / 目录", summary.root],
65
+ ["Harness", summary.harnesses.join(" · ")],
66
+ ["Mode / 模式", summary.background ? "Background / 后台" : "Foreground / 前台"],
67
+ ["PID", String(summary.pid)],
68
+ ["Logs / 日志", summary.diagnosticsPath],
69
+ ];
70
+ for (const [name, text] of rows)
71
+ process.stdout.write(` ${name} ${text}\n`);
72
+ process.stdout.write(`\n cohub runtime logs --space ${summary.spaceId} --follow\n cohub runtime down --space ${summary.spaceId}\n`);
73
+ if (!summary.background && !reused)
74
+ process.stdout.write(" Ctrl+C to stop / 按 Ctrl+C 停止\n");
75
+ process.stdout.write("\n");
76
+ }
@@ -30,6 +30,8 @@ export type NativeSession = {
30
30
  sourceFingerprint?: string | null;
31
31
  nativeLeafId?: string | null;
32
32
  };
33
+ /** Reverse lookup for native clients; the existing Runtime state remains authoritative. */
34
+ export declare function findRuntimeNativeSession(root: string, harness: "pi" | "codex", nativeSessionId: string, path?: string): Promise<NativeSession | null>;
33
35
  /** Local references are hints validated against actual native files, never cloud existence claims. */
34
36
  export declare class RuntimeSessionStore {
35
37
  readonly root: string;
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises";
2
+ import { mkdir, open, readFile, readdir, realpath, rename, rm } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { RUNTIME_RECOVERY_BATCH_SIZE, runtimeEventSchema, serializeProjectionRecords } from "@neta-art/cohub";
@@ -9,6 +9,35 @@ import { serializeDiagnosticError } from "./diagnostics.js";
9
9
  import { ProjectionStore, rebindProjectionNativeSession } from "./projection-store.js";
10
10
  export class ContextRequiredError extends Error {
11
11
  }
12
+ /** Reverse lookup for native clients; the existing Runtime state remains authoritative. */
13
+ export async function findRuntimeNativeSession(root, harness, nativeSessionId, path) {
14
+ const directory = join(root, harness);
15
+ const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
16
+ return []; throw error; });
17
+ const matches = [];
18
+ for (const name of names) {
19
+ if (!name.endsWith(".json"))
20
+ continue;
21
+ const state = JSON.parse(await readFile(join(directory, name), "utf8"));
22
+ if (state.version === 1 && state.harness === harness && state.nativeSessionId === nativeSessionId)
23
+ matches.push(state);
24
+ }
25
+ if (path && matches.length) {
26
+ const canonical = await realpath(path);
27
+ const exact = [];
28
+ for (const state of matches) {
29
+ const candidate = await realpath(state.path).catch((error) => { if (error.code === "ENOENT")
30
+ return null; throw error; });
31
+ if (candidate === canonical)
32
+ exact.push(state);
33
+ }
34
+ if (exact.length === 1)
35
+ return exact[0] ?? null;
36
+ }
37
+ if (matches.length > 1)
38
+ throw new Error("Native Session has ambiguous Runtime bindings / 原生会话存在多个 Runtime 关联");
39
+ return matches[0] ?? null;
40
+ }
12
41
  const checksum = (data) => createHash("sha256").update(data).digest("hex");
13
42
  const missing = (error) => error?.code === "ENOENT";
14
43
  class CaptureUnavailableError extends Error {
@@ -240,7 +269,6 @@ export class RuntimeSessionStore {
240
269
  }
241
270
  catch (error) {
242
271
  this.diagnostics?.log("error", "runtime.session_state_unreadable", { path: join(directory, name), error: serializeDiagnosticError(error) });
243
- console.error(`Runtime session state unreadable: ${join(directory, name)}`, error);
244
272
  }
245
273
  }
246
274
  }
@@ -295,11 +323,9 @@ export class RuntimeSessionStore {
295
323
  });
296
324
  await rm(join(captures, name), { force: true });
297
325
  this.diagnostics?.log("error", "archive.capture_unavailable", { reason: error.message, receipt: true }, { component: "archive" });
298
- console.error("Archive capture unavailable; receipt retained:", error.message);
299
326
  }
300
327
  else {
301
328
  this.diagnostics?.log("warn", "archive.capture_pending", { error: serializeDiagnosticError(error) }, { component: "archive" });
302
- console.error("Archive capture pending:", error);
303
329
  }
304
330
  }
305
331
  }
@@ -368,6 +394,16 @@ export class RuntimeSessionStore {
368
394
  }
369
395
  }
370
396
  }
397
+ if (previous) {
398
+ const previousPath = previous.path;
399
+ const canonical = await realpath(previousPath).catch((error) => { if (missing(error))
400
+ return previousPath; throw error; });
401
+ const externallyOwned = await readFile(join(this.root, "native-owners", `${checksum(canonical)}.json`), "utf8").then(() => true).catch((error) => { if (missing(error))
402
+ return false; throw error; });
403
+ // Interactive clients own their native files, including symlink aliases. Use an independent projection.
404
+ if (externallyOwned)
405
+ previous = null;
406
+ }
371
407
  if (previous) {
372
408
  try {
373
409
  const nativeChecksum = await checksumNativeFile(previous.path);
@@ -408,7 +444,6 @@ export class RuntimeSessionStore {
408
444
  catch (error) {
409
445
  signal?.throwIfAborted();
410
446
  this.diagnostics?.log("warn", "archive.restore_failed", { error: serializeDiagnosticError(error) }, { component: "archive" });
411
- console.error("Native archive unavailable; rebuilding from durable history:", error);
412
447
  }
413
448
  }
414
449
  return await this.syncNativeProjection(input, cwd, state, signal);
@@ -39,6 +39,9 @@ export declare function resolveRuntimeSpace(input: {
39
39
  root: string;
40
40
  identityKey: string | null | undefined;
41
41
  explicitSpaceId?: string | null;
42
+ /** Explicit new selection; compare the binding observed before prompting. */
43
+ newSpace?: boolean;
44
+ expectedSpaceId?: string | null;
42
45
  createSpace: () => Promise<string>;
43
46
  validateSpace?: (spaceId: string) => Promise<void>;
44
47
  path?: string;
@@ -99,7 +99,7 @@ function upsertRuntimeSpaceBinding(file, binding) {
99
99
  bindings[index] = nextBinding;
100
100
  return { file: { version: 1, bindings }, changed: true };
101
101
  }
102
- async function writeRuntimeSpaceBindings(path, file) {
102
+ async function writeRuntimeJson(path, file) {
103
103
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
104
104
  const temporary = `${path}.${randomUUID()}.tmp`;
105
105
  try {
@@ -248,7 +248,7 @@ async function persistRuntimeSpaceBinding(path, binding) {
248
248
  const file = await readRuntimeSpaceBindings(path);
249
249
  const result = upsertRuntimeSpaceBinding(file, binding);
250
250
  if (result.changed)
251
- await writeRuntimeSpaceBindings(path, result.file);
251
+ await writeRuntimeJson(path, result.file);
252
252
  }, { path, lockPath: `${path}${GLOBAL_LOCK_SUFFIX}` });
253
253
  }
254
254
  export async function getRuntimeSpaceBinding(root, key, path = runtimeSpaceBindingsPath()) {
@@ -281,25 +281,62 @@ export async function resolveRuntimeSpace(input) {
281
281
  const lockPath = bindingLockPath(path, root, bindingKey);
282
282
  return withRuntimeSpaceBindingsLock(async () => {
283
283
  const file = await readRuntimeSpaceBindings(path);
284
+ const receiptPath = `${lockPath}.creation.json`;
284
285
  if (explicitSpaceId) {
285
286
  await input.validateSpace?.(explicitSpaceId);
286
287
  await persistRuntimeSpaceBinding(path, { root, key: bindingKey, spaceId: explicitSpaceId });
288
+ // Preserve an ambiguous creation receipt for diagnosis, rather than deleting it.
289
+ if (await statIfPresent(receiptPath))
290
+ await rename(receiptPath, `${receiptPath}.${randomUUID()}.resolved`);
287
291
  return { spaceId: explicitSpaceId, source: "explicit" };
288
292
  }
289
293
  const existing = findRuntimeSpaceBinding(file, { root, key: bindingKey });
290
- if (existing) {
294
+ if (input.newSpace && (existing?.spaceId ?? null) !== (input.expectedSpaceId ?? null)) {
295
+ throw new RuntimeSpaceBindingsError("Directory binding changed; run up again / 目录绑定已变化,请重新运行 up");
296
+ }
297
+ if (existing && !input.newSpace) {
291
298
  await input.validateSpace?.(existing.spaceId);
292
299
  return { spaceId: existing.spaceId, source: "binding" };
293
300
  }
294
- // Remote creation and the local binding commit are separate durability
295
- // domains; a server-side idempotency key is needed to close this crash window.
296
- const createdSpaceId = await input.createSpace();
301
+ // Fail closed across the remote-create/local-commit crash window. A saved ID
302
+ // resumes binding; an ambiguous request must be resolved with --space, never replayed.
303
+ let receipt = null;
304
+ try {
305
+ receipt = JSON.parse(await readFile(receiptPath, "utf8"));
306
+ if (!receipt || !nonEmptyString(receipt.operationId) || receipt.spaceId !== undefined && !nonEmptyString(receipt.spaceId)) {
307
+ throw new RuntimeSpaceBindingsError(`Invalid creation receipt; original retained / 创建回执无效,原始信息已保留: ${receiptPath}`);
308
+ }
309
+ }
310
+ catch (error) {
311
+ if (!missing(error))
312
+ throw error;
313
+ }
314
+ if (receipt && !receipt.spaceId)
315
+ throw new RuntimeSpaceBindingsError(`A previous Space creation has an unknown outcome. Check your Spaces and use --space <id> / 上次创建结果未知,请检查 Space 后使用 --space <id>。${receiptPath}`);
316
+ if (!receipt) {
317
+ receipt = { operationId: randomUUID() };
318
+ await writeRuntimeJson(receiptPath, receipt);
319
+ }
320
+ let createdSpaceId = receipt.spaceId;
321
+ if (!createdSpaceId) {
322
+ try {
323
+ createdSpaceId = await input.createSpace();
324
+ }
325
+ catch (error) {
326
+ const status = error?.status;
327
+ if (status && status >= 400 && status < 500 && status !== 408)
328
+ await rm(receiptPath);
329
+ throw error;
330
+ }
331
+ await writeRuntimeJson(receiptPath, { ...receipt, spaceId: createdSpaceId });
332
+ }
297
333
  if (!nonEmptyString(createdSpaceId)) {
298
334
  throw new RuntimeSpaceBindingsError("Local Runtime Space creation returned no Space ID");
299
335
  }
300
336
  const spaceId = createdSpaceId.trim();
301
337
  await input.validateSpace?.(spaceId);
302
338
  await persistRuntimeSpaceBinding(path, { root, key: bindingKey, spaceId });
339
+ await rm(receiptPath);
303
340
  return { spaceId, source: "created" };
304
341
  }, { path, lockPath });
305
342
  }
@@ -0,0 +1,16 @@
1
+ import { type RuntimeCapabilities } from "@neta-art/cohub";
2
+ import { type HarnessOptions } from "./harness.js";
3
+ import { type RuntimeDiagnostic, type RuntimeDiagnosticLevel } from "./diagnostics.js";
4
+ import { type RuntimeSummary } from "./presentation.js";
5
+ export type RuntimeLaunch = {
6
+ spaceId: string;
7
+ root: string;
8
+ identity: string;
9
+ harnesses: ("pi" | "codex")[];
10
+ executables: HarnessOptions;
11
+ capabilities?: RuntimeCapabilities;
12
+ background: boolean;
13
+ verbose?: boolean;
14
+ };
15
+ export declare function sandboxOutputLevel(value: unknown, stream: "stdout" | "stderr"): RuntimeDiagnosticLevel;
16
+ export declare function runRuntime(config: RuntimeLaunch, onState: (status: RuntimeSummary) => void, externalSignal?: AbortSignal, onDiagnostic?: (event: RuntimeDiagnostic) => void): Promise<void>;