@neta-art/cohub-cli 7.1.2 → 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.
Files changed (48) 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 +169 -293
  6. package/dist/commands/sandboxd-binary.d.ts +1 -1
  7. package/dist/commands/sandboxd-binary.js +13 -5
  8. package/dist/runtime/archive-store.d.ts +5 -1
  9. package/dist/runtime/archive-store.js +22 -6
  10. package/dist/runtime/connection.d.ts +4 -2
  11. package/dist/runtime/connection.js +113 -44
  12. package/dist/runtime/diagnostics.d.ts +3 -0
  13. package/dist/runtime/diagnostics.js +3 -0
  14. package/dist/runtime/harness.d.ts +7 -0
  15. package/dist/runtime/harness.js +63 -17
  16. package/dist/runtime/instance.d.ts +5 -0
  17. package/dist/runtime/instance.js +159 -0
  18. package/dist/runtime/json-rpc.d.ts +2 -0
  19. package/dist/runtime/json-rpc.js +2 -0
  20. package/dist/runtime/launch.d.ts +20 -0
  21. package/dist/runtime/launch.js +176 -0
  22. package/dist/runtime/native-codex-hook.d.ts +1 -0
  23. package/dist/runtime/native-codex-hook.js +28 -0
  24. package/dist/runtime/native-install.d.ts +21 -0
  25. package/dist/runtime/native-install.js +130 -0
  26. package/dist/runtime/native-ipc.d.ts +26 -0
  27. package/dist/runtime/native-ipc.js +101 -0
  28. package/dist/runtime/native-pi-extension.d.ts +20 -0
  29. package/dist/runtime/native-pi-extension.js +47 -0
  30. package/dist/runtime/native-sync-store.d.ts +97 -0
  31. package/dist/runtime/native-sync-store.js +365 -0
  32. package/dist/runtime/native-sync.d.ts +25 -0
  33. package/dist/runtime/native-sync.js +128 -0
  34. package/dist/runtime/native-transcript.d.ts +27 -0
  35. package/dist/runtime/native-transcript.js +281 -0
  36. package/dist/runtime/presentation.d.ts +21 -0
  37. package/dist/runtime/presentation.js +78 -0
  38. package/dist/runtime/process-group.d.ts +2 -0
  39. package/dist/runtime/process-group.js +124 -31
  40. package/dist/runtime/session-store.d.ts +17 -2
  41. package/dist/runtime/session-store.js +118 -9
  42. package/dist/runtime/space-binding.d.ts +3 -0
  43. package/dist/runtime/space-binding.js +43 -6
  44. package/dist/runtime/supervisor.d.ts +16 -0
  45. package/dist/runtime/supervisor.js +277 -0
  46. package/dist/runtime/worker.d.ts +1 -0
  47. package/dist/runtime/worker.js +20 -0
  48. package/package.json +3 -2
@@ -0,0 +1,128 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { createClient } from "../client.js";
5
+ import { currentIdentityKey } from "../space.js";
6
+ import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
7
+ import { readNativeTranscript } from "./native-transcript.js";
8
+ import { findRuntimeNativeSession } from "./session-store.js";
9
+ import { listNativeSyncStores, nativeIdentityHash, NativeSyncStore } from "./native-sync-store.js";
10
+ export const nativeRuntimeRoot = (spaceId) => join(homedir(), ".local", "state", "cohub", "runtime", spaceId);
11
+ export const nativeSyncConfigPath = (runtimeRoot, identity) => join(runtimeRoot, "native", nativeIdentityHash(identity), "config.json");
12
+ export function nativeArchiveTransport(spaceId, identity) {
13
+ const client = createClient().space(spaceId);
14
+ const guard = async (task) => {
15
+ if (currentIdentityKey() !== identity)
16
+ throw new Error("Native sync account changed");
17
+ const result = await task();
18
+ if (currentIdentityKey() !== identity)
19
+ throw new Error("Native sync account changed");
20
+ return result;
21
+ };
22
+ return {
23
+ prepareRuntimeArchive: (...args) => guard(() => client.prepareRuntimeArchive(...args)),
24
+ commitRuntimeArchive: (...args) => guard(() => client.commitRuntimeArchive(...args)),
25
+ getRuntimeArchive: (...args) => guard(() => client.getRuntimeArchive(...args)),
26
+ };
27
+ }
28
+ export async function readNativeSyncConfig(runtimeRoot, identity) {
29
+ try {
30
+ const config = JSON.parse(await readFile(nativeSyncConfigPath(runtimeRoot, identity), "utf8"));
31
+ if (config.version !== 1 || config.identity !== identity || !Array.isArray(config.harnesses))
32
+ throw new Error("Invalid native sync configuration");
33
+ return config;
34
+ }
35
+ catch (error) {
36
+ if (error.code === "ENOENT")
37
+ return null;
38
+ throw error;
39
+ }
40
+ }
41
+ const nativeStores = new Map();
42
+ /** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
43
+ export async function captureNativeSession(input) {
44
+ if (process.env.COHUB_TURN_ID || process.env.COHUB_EXECUTION_TOKEN)
45
+ return null;
46
+ const identity = currentIdentityKey();
47
+ if (!identity)
48
+ return null;
49
+ const root = await canonicalRuntimeRoot(input.cwd);
50
+ const space = await getRuntimeSpaceBinding(root, identity);
51
+ if (!space)
52
+ return null;
53
+ const runtimeRoot = nativeRuntimeRoot(space.spaceId);
54
+ const config = await readNativeSyncConfig(runtimeRoot, identity);
55
+ if (!config || config.root !== root || config.spaceId !== space.spaceId || !config.harnesses.includes(input.harness))
56
+ return null;
57
+ const path = await canonicalRuntimeRoot(input.path);
58
+ const transcript = await readNativeTranscript(path, input.harness, input);
59
+ if (await canonicalRuntimeRoot(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
60
+ throw new Error("Native transcript belongs to another project or Session");
61
+ const key = JSON.stringify([identity, space.spaceId, input.harness, transcript.nativeSessionId, path]);
62
+ let store = nativeStores.get(key);
63
+ if (!store) {
64
+ const transport = nativeArchiveTransport(space.spaceId, identity);
65
+ const candidates = (await listNativeSyncStores(runtimeRoot, space.spaceId, identity, transport))
66
+ .filter((candidate) => candidate.options.harness === input.harness && candidate.options.nativeSessionId === transcript.nativeSessionId);
67
+ for (const candidate of candidates)
68
+ if ((await candidate.binding()).path === path) {
69
+ store = candidate;
70
+ break;
71
+ }
72
+ if (!store) {
73
+ const managed = await findRuntimeNativeSession(runtimeRoot, input.harness, transcript.nativeSessionId, path);
74
+ const managedPath = managed ? await canonicalRuntimeRoot(managed.path).catch((error) => { if (error.code === "ENOENT")
75
+ return null; throw error; }) : null;
76
+ if (candidates.length && managedPath !== path)
77
+ throw new Error("Native path changed; original bindings retained");
78
+ // Restored Pi working copies can share a native ID. Existing Cohub sidecars disambiguate them.
79
+ store = new NativeSyncStore({ runtimeRoot, spaceId: space.spaceId, identity, harness: input.harness, nativeSessionId: transcript.nativeSessionId,
80
+ instanceKey: managedPath === path ? path : undefined, transport });
81
+ }
82
+ if (nativeStores.size >= 256)
83
+ nativeStores.delete(nativeStores.keys().next().value ?? "");
84
+ nativeStores.set(key, store);
85
+ }
86
+ await store.capture(path, transcript);
87
+ return store;
88
+ }
89
+ /** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
90
+ export function nativeWebSocketTransport(spaceId, identity, send) {
91
+ const archive = nativeArchiveTransport(spaceId, identity);
92
+ return {
93
+ ...archive,
94
+ startNativeTurn: async (input) => await send({ type: "start", input }),
95
+ completeNativeTurn: async (sessionId, turnId, result) => await send({ type: "complete", sessionId, turnId, result }),
96
+ updateNativeTurn: async (sessionId, turnId, progress) => await send({ type: "progress", sessionId, turnId, progress }),
97
+ heartbeatNativeTurn: async (sessionId, turnId) => await send({ type: "heartbeat", sessionId, turnId }),
98
+ };
99
+ }
100
+ export async function flushNativeSessions(spaceId, identity, signal, report, transportOverride) {
101
+ if (currentIdentityKey() !== identity)
102
+ return;
103
+ const runtimeRoot = nativeRuntimeRoot(spaceId);
104
+ const config = await readNativeSyncConfig(runtimeRoot, identity);
105
+ if (!config || config.spaceId !== spaceId)
106
+ return;
107
+ const transport = transportOverride;
108
+ if (!transport)
109
+ return;
110
+ const stores = await listNativeSyncStores(runtimeRoot, spaceId, identity, transport);
111
+ for (const store of stores) {
112
+ signal.throwIfAborted();
113
+ const binding = await store.binding();
114
+ if (!config.harnesses.includes(binding.harness))
115
+ continue;
116
+ try {
117
+ // Codex hooks only push capture requests while its terminal lives; the Daemon keeps re-reading
118
+ // the transcript between hooks so Stop-flushed Turns are picked up even if a hook is missed.
119
+ if (binding.harness === "codex")
120
+ await store.capture(binding.path, await readNativeTranscript(binding.path, "codex"));
121
+ await store.flush(AbortSignal.any([signal, AbortSignal.timeout(30_000)]));
122
+ }
123
+ catch (error) {
124
+ if (!signal.aborted)
125
+ report(error);
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,27 @@
1
+ import type { ContentBlock, NativeTurnComplete, NativeTurnMessage } from "@neta-art/cohub";
2
+ export type NativeTranscriptTurn = {
3
+ key: string;
4
+ parentKey: string | null;
5
+ cloudTurnId?: string;
6
+ userContent: ContentBlock[];
7
+ messages: NativeTurnMessage[];
8
+ startedAt: string;
9
+ startBytes: number;
10
+ endBytes: number;
11
+ contentEndBytes: number;
12
+ boundaries: Record<number, string>;
13
+ sha256: string;
14
+ result: NativeTurnComplete | null;
15
+ };
16
+ export type NativeTranscript = {
17
+ nativeSessionId: string;
18
+ cwd: string;
19
+ cloudSessionId?: string;
20
+ turns: NativeTranscriptTurn[];
21
+ prefixes: ReadonlyMap<number, string>;
22
+ };
23
+ /** Partial trailing records are retried, never parsed or acknowledged as complete. */
24
+ export declare function readNativeTranscript(path: string, harness: "pi" | "codex", options?: {
25
+ settled?: boolean;
26
+ leafId?: string | null;
27
+ }): Promise<NativeTranscript>;
@@ -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");
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");
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");
73
+ visited.add(leaf);
74
+ const entry = entries.get(leaf);
75
+ if (!entry)
76
+ throw new Error("Pi parent history is missing");
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");
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");
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");
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");
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");
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,78 @@
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",
7
+ "runtime.available": "Runtime ready",
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",
12
+ "runtime.stopped": "Runtime stopped",
13
+ "runtime.failed": "Runtime needs attention",
14
+ "runtime.turn_failed": "Turn failed; local files retained",
15
+ "runtime.turn_cleanup_pending": "Turn result saved; tool cleanup still unconfirmed",
16
+ "runtime.connection_failed": "Connection attempt failed; retrying",
17
+ "runtime.execution_transport_detached": "Execution continues locally; result replays after reconnect",
18
+ "runtime.execution_transport_invalidated": "Execution interrupted; outcome needs reconciliation",
19
+ "archive.upload_pending": "Archive upload pending; local data retained",
20
+ "native.sync_pending": "Native sync pending; local records retained",
21
+ "archive.capture_pending": "Archive capture pending",
22
+ "archive.capture_unavailable": "Archive unavailable; original receipt retained",
23
+ "archive.restore_failed": "Native restore unavailable; using saved history",
24
+ "sandboxd.process_exit": "File bridge stopped; restarting",
25
+ "sandboxd.download": "Preparing file bridge",
26
+ "sandboxd.connected": "File bridge connected",
27
+ "sandboxd.disconnected": "File bridge disconnected; reconnecting",
28
+ };
29
+ export function formatDiagnostic(event, verbose = false) {
30
+ const text = messages[event.event] ?? (typeof event.data?.message === "string" ? event.data.message : event.event);
31
+ const detail = event.error?.message ? ` — ${event.error.message}` : "";
32
+ const context = verbose ? ` ${JSON.stringify({ ...event.data, runtimeId: event.runtimeId, sessionId: event.sessionId, turnId: event.turnId })}` : "";
33
+ return `${event.timestamp.slice(11, 19)} ${event.level.toUpperCase().padEnd(5)} ${text}${detail}${context}\n`;
34
+ }
35
+ /** File logs retain every event; repeated terminal warnings are coalesced. */
36
+ export function createDiagnosticConsole(verbose = false, write = (line) => process.stderr.write(line)) {
37
+ const last = new Map();
38
+ return (event) => {
39
+ if (!verbose && (event.level === "debug" || !atLeastLevel(event.level, "warn") && !messages[event.event]))
40
+ return;
41
+ const key = `${event.component}:${event.event}:${event.level}:${event.error?.message ?? event.data?.message ?? ""}`;
42
+ const previous = last.get(key);
43
+ const now = Date.now();
44
+ if (!verbose && previous && now - previous.at < 30_000 && atLeastLevel(event.level, "warn")) {
45
+ previous.suppressed++;
46
+ return;
47
+ }
48
+ if (last.size >= 256)
49
+ last.delete(last.keys().next().value ?? "");
50
+ last.set(key, { at: now, suppressed: 0 });
51
+ const repeated = previous?.suppressed ? ` (+${previous.suppressed} repeated)` : "";
52
+ write(`${formatDiagnostic(event, verbose).trimEnd()}${repeated}\n`);
53
+ };
54
+ }
55
+ export function printRuntimeSummary(summary, json = false, reused = false) {
56
+ const value = { ...summary, url: runtimeWebUrl(summary.spaceId), reused };
57
+ if (json) {
58
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
59
+ return;
60
+ }
61
+ const label = summary.state === "ready" ? "Runtime ready" : `Runtime ${summary.state}`;
62
+ process.stdout.write(`\n${label}${reused ? " · reused" : ""}\n\n`);
63
+ const rows = [
64
+ ["Space", summary.spaceId],
65
+ ["URL", value.url],
66
+ ["Directory", summary.root],
67
+ ["Harness", summary.harnesses.join(" · ")],
68
+ ["Mode", summary.background ? "Background" : "Foreground"],
69
+ ["PID", String(summary.pid)],
70
+ ["Logs", summary.diagnosticsPath],
71
+ ];
72
+ for (const [name, text] of rows)
73
+ process.stdout.write(` ${name} ${text}\n`);
74
+ process.stdout.write(`\n cohub runtime logs --space ${summary.spaceId} --follow\n cohub runtime down --space ${summary.spaceId}\n`);
75
+ if (!summary.background && !reused)
76
+ process.stdout.write(" Ctrl+C to stop\n");
77
+ process.stdout.write("\n");
78
+ }
@@ -1,3 +1,5 @@
1
1
  export declare class ProcessCleanupUncertainError extends Error {
2
2
  }
3
+ /** True only when the process group is gone or holds nothing that can execute. */
4
+ export declare function confirmQuiescentProcessGroup(pid: number): Promise<boolean>;
3
5
  export declare function stopProcessGroup(pid: number): Promise<void>;