@neta-art/cohub-cli 8.3.1 → 8.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/commands/generations.js +104 -30
- package/dist/commands/spaces.js +38 -0
- package/dist/runtime/connection.js +13 -7
- package/dist/runtime/harness.d.ts +2 -0
- package/dist/runtime/harness.js +6 -2
- package/dist/runtime/json-rpc.d.ts +4 -0
- package/dist/runtime/json-rpc.js +7 -3
- package/dist/runtime/native/execution.js +5 -2
- package/dist/runtime/native/transcript.js +20 -9
- package/dist/runtime/native/translate.js +13 -10
- package/dist/runtime/presentation.js +1 -0
- package/dist/runtime/process-group.js +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -178,6 +178,7 @@ Scheduling rules:
|
|
|
178
178
|
cohub -s <spaceId> spaces sessions ls --json
|
|
179
179
|
cohub -s <spaceId> spaces sessions create "<title>" --json
|
|
180
180
|
cohub -s <spaceId> spaces sessions get <sessionId> --json
|
|
181
|
+
cohub -s <spaceId> spaces sessions files <sessionId> --json
|
|
181
182
|
cohub -s <spaceId> spaces sessions rename <sessionId> "<new title>"
|
|
182
183
|
```
|
|
183
184
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { openAsBlob } from "node:fs";
|
|
1
2
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, extname, join } from "node:path";
|
|
3
4
|
import { GenerationPolicyError, assertGenerationRequestAllowedByPolicy, parseGenerationPolicyFromEnv, } from "@neta-art/cohub";
|
|
5
|
+
import { probeMediaInfo } from "@neta-art/cohub/media";
|
|
4
6
|
import { createClient } from "../client.js";
|
|
5
7
|
import { resolveSpace } from "../space.js";
|
|
6
8
|
import { json as outJson, jsonRequested, ok, error, handleHttp, spinner } from "../output.js";
|
|
@@ -59,36 +61,48 @@ async function pathExists(path) {
|
|
|
59
61
|
async function parseMediaInput(type, rawValue) {
|
|
60
62
|
const separator = rawValue.indexOf("=");
|
|
61
63
|
if (separator <= 0)
|
|
62
|
-
return { value: rawValue };
|
|
64
|
+
return { type, value: rawValue };
|
|
63
65
|
const role = rawValue.slice(0, separator).trim();
|
|
64
66
|
if (!mediaRoles.has(role))
|
|
65
|
-
return { value: rawValue };
|
|
67
|
+
return { type, value: rawValue };
|
|
66
68
|
if (await pathExists(rawValue))
|
|
67
|
-
return { value: rawValue };
|
|
69
|
+
return { type, value: rawValue };
|
|
68
70
|
if (!rolesByMediaType[type].has(role)) {
|
|
69
71
|
return error("Invalid media role", `${role} cannot be used with --${type}`);
|
|
70
72
|
}
|
|
71
73
|
const value = rawValue.slice(separator + 1).trim();
|
|
72
74
|
if (!value)
|
|
73
75
|
return error("Invalid media input", `--${type} ${role}= requires a path or URL`);
|
|
74
|
-
return { value, role };
|
|
76
|
+
return { type, value, role };
|
|
75
77
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Local inputs upload to an unlisted public URL so tasks store a reference, not
|
|
80
|
+
* megabytes of base64. Inline data remains for `--inline`, types the upload
|
|
81
|
+
* does not accept, and failed uploads.
|
|
82
|
+
*/
|
|
83
|
+
async function mediaContent({ type, value, role }, upload) {
|
|
84
|
+
const block = (source) => ({ type, source, ...(role ? { meta: { role } } : {}) });
|
|
85
|
+
if (/^https?:\/\//.test(value))
|
|
86
|
+
return block({ type: "url", url: value });
|
|
87
|
+
const mediaType = mimeByExt[extname(value).toLowerCase()];
|
|
88
|
+
if (mediaType && upload) {
|
|
89
|
+
try {
|
|
90
|
+
return block({ type: "url", url: await upload(value, mediaType) });
|
|
91
|
+
}
|
|
92
|
+
catch (uploadError) {
|
|
93
|
+
const reason = uploadError instanceof Error ? uploadError.message : String(uploadError);
|
|
94
|
+
process.stderr.write(` Could not upload ${value} (${reason}); sending it inline.\n`);
|
|
95
|
+
}
|
|
81
96
|
}
|
|
82
97
|
const data = await readFile(value);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
};
|
|
98
|
+
return block({
|
|
99
|
+
type: "base64",
|
|
100
|
+
mediaType: mediaType ?? "application/octet-stream",
|
|
101
|
+
data: data.toString("base64"),
|
|
102
|
+
});
|
|
89
103
|
}
|
|
90
|
-
function validateMediaRoleModes(
|
|
91
|
-
const roles =
|
|
104
|
+
function validateMediaRoleModes(inputs) {
|
|
105
|
+
const roles = inputs.flatMap(({ role }) => (role ? [role] : []));
|
|
92
106
|
const hasFrameRole = roles.some((role) => frameMediaRoles.has(role));
|
|
93
107
|
const hasReferenceRole = roles.some((role) => referenceMediaRoles.has(role));
|
|
94
108
|
if (hasFrameRole && hasReferenceRole) {
|
|
@@ -174,13 +188,37 @@ function formatOutputLabel(block) {
|
|
|
174
188
|
function slugOutputLabel(block) {
|
|
175
189
|
return slugify(metaRole(block) ?? block.type);
|
|
176
190
|
}
|
|
177
|
-
|
|
178
|
-
|
|
191
|
+
const PROBE_TIMEOUT_MS = 3_000;
|
|
192
|
+
/** Size, duration, and frames of URL outputs, probed in parallel; best effort. */
|
|
193
|
+
async function probeOutputs(output) {
|
|
194
|
+
const signal = AbortSignal.timeout(PROBE_TIMEOUT_MS);
|
|
195
|
+
const probed = await Promise.all(output.map(async (block, index) => {
|
|
196
|
+
if (block.type === "text" || block.source.type !== "url")
|
|
197
|
+
return null;
|
|
198
|
+
const info = await probeMediaInfo(block.source.url, { type: block.type, signal });
|
|
199
|
+
return Object.keys(info).length > 0 ? { index, ...info } : null;
|
|
200
|
+
}));
|
|
201
|
+
return probed.filter((media) => media !== null);
|
|
202
|
+
}
|
|
203
|
+
function mediaSummary(info) {
|
|
204
|
+
const parts = [
|
|
205
|
+
info?.width && info.height ? `${info.width}×${info.height}` : null,
|
|
206
|
+
info?.durationMs ? `${(info.durationMs / 1000).toFixed(1)}s` : null,
|
|
207
|
+
].filter(Boolean);
|
|
208
|
+
return parts.length > 0 ? ` ${parts.join(" · ")}` : "";
|
|
209
|
+
}
|
|
210
|
+
function printGeneration(output, media) {
|
|
211
|
+
const byIndex = new Map(media.map((info) => [info.index, info]));
|
|
212
|
+
for (const [index, block] of output.entries()) {
|
|
179
213
|
if (block.type === "text") {
|
|
180
214
|
console.log(block.text);
|
|
181
215
|
}
|
|
182
216
|
else if (block.source.type === "url") {
|
|
183
|
-
|
|
217
|
+
const info = byIndex.get(index);
|
|
218
|
+
console.log(`${formatOutputLabel(block)}${mediaSummary(info)}: ${block.source.url}`);
|
|
219
|
+
// The last frame chains into the next clip as its first_frame.
|
|
220
|
+
if (info?.lastFrameUrl)
|
|
221
|
+
console.log(` last frame: ${info.lastFrameUrl}`);
|
|
184
222
|
}
|
|
185
223
|
else {
|
|
186
224
|
console.log(`${formatOutputLabel(block)}: base64 ${block.source.mediaType} (${block.source.data.length} chars)`);
|
|
@@ -242,11 +280,15 @@ export function registerGenerations(program) {
|
|
|
242
280
|
.option("--parameters <json>", "Generation parameters as a JSON object")
|
|
243
281
|
.option("--meta <json>", "Meta as a JSON object")
|
|
244
282
|
.option("-o, --output <path>", "Save generated output to a file or directory")
|
|
283
|
+
.option("--inline", "Send local input files inline instead of uploading them to an unlisted public URL")
|
|
245
284
|
.option("--async", "Queue the generation task and return immediately")
|
|
246
285
|
.option("--timeout-ms <ms>", "Maximum time to wait in synchronous mode")
|
|
247
286
|
.option("--json", "Output as JSON")
|
|
248
287
|
.addHelpText("after", `
|
|
249
288
|
|
|
289
|
+
Local input files upload to an unlisted public URL before the task is created;
|
|
290
|
+
pass --inline to keep them inside the task instead.
|
|
291
|
+
|
|
250
292
|
Examples:
|
|
251
293
|
cohub models ls --model-type multimodal
|
|
252
294
|
cohub -s <space-id> generate "A calm lake at sunrise" -m <model> -o lake.png
|
|
@@ -259,11 +301,18 @@ Examples:
|
|
|
259
301
|
.action(async (prompt, opts) => {
|
|
260
302
|
try {
|
|
261
303
|
const spaceId = await resolveSpace(program);
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
304
|
+
const sessionId = envValue("COHUB_SESSION_ID");
|
|
305
|
+
const inputs = await Promise.all([
|
|
306
|
+
...opts.image.map((value) => parseMediaInput("image", value)),
|
|
307
|
+
...opts.video.map((value) => parseMediaInput("video", value)),
|
|
308
|
+
...opts.audio.map((value) => parseMediaInput("audio", value)),
|
|
309
|
+
]);
|
|
310
|
+
validateMediaRoleModes(inputs);
|
|
311
|
+
for (const { value } of inputs) {
|
|
312
|
+
if (!/^https?:\/\//.test(value) && !(await pathExists(value))) {
|
|
313
|
+
return error("Input not found", value);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
267
316
|
const parameters = parseParams(opts.param, opts.parameters);
|
|
268
317
|
try {
|
|
269
318
|
assertGenerationRequestAllowedByPolicy({
|
|
@@ -279,9 +328,25 @@ Examples:
|
|
|
279
328
|
}
|
|
280
329
|
const meta = parseMeta(opts.meta);
|
|
281
330
|
const client = createClient();
|
|
331
|
+
// Validate everything first, so rejected requests never upload files.
|
|
332
|
+
const upload = opts.inline ? null : async (path, mimeType) => {
|
|
333
|
+
const asset = await client.publicAssets.uploadGenerationInput({
|
|
334
|
+
spaceId,
|
|
335
|
+
sessionId,
|
|
336
|
+
file: await openAsBlob(path, { type: mimeType }),
|
|
337
|
+
mimeType,
|
|
338
|
+
filename: basename(path),
|
|
339
|
+
});
|
|
340
|
+
process.stderr.write(` Uploaded ${path} to an unlisted public URL; use --inline to keep inputs private.\n`);
|
|
341
|
+
return asset.publicUrl;
|
|
342
|
+
};
|
|
343
|
+
const content = [
|
|
344
|
+
{ type: "text", text: prompt },
|
|
345
|
+
...await Promise.all(inputs.map((input) => mediaContent(input, upload))),
|
|
346
|
+
];
|
|
282
347
|
const created = await client.generations.create({
|
|
283
348
|
spaceId,
|
|
284
|
-
sessionId
|
|
349
|
+
sessionId,
|
|
285
350
|
turnId: envValue("COHUB_TURN_ID"),
|
|
286
351
|
model: opts.model,
|
|
287
352
|
content,
|
|
@@ -310,10 +375,19 @@ Examples:
|
|
|
310
375
|
});
|
|
311
376
|
if (!jsonRequested(opts))
|
|
312
377
|
spin.stop(`Generation completed — task ID: ${created.taskRunId}, ${formatElapsed(Date.now() - waitStartedAt)}, ${pollCount} polls`);
|
|
313
|
-
const savedPaths =
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
378
|
+
const [savedPaths, outputMedia] = await Promise.all([
|
|
379
|
+
opts.output ? saveOutputs(result.output, opts.output) : Promise.resolve([]),
|
|
380
|
+
probeOutputs(result.output),
|
|
381
|
+
]);
|
|
382
|
+
if (jsonRequested(opts)) {
|
|
383
|
+
return outJson({
|
|
384
|
+
...result,
|
|
385
|
+
taskRunId: created.taskRunId,
|
|
386
|
+
...(outputMedia.length > 0 ? { outputMedia } : {}),
|
|
387
|
+
...(savedPaths.length > 0 ? { savedPaths } : {}),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
printGeneration(result.output, outputMedia);
|
|
317
391
|
if (result.requestId || result.cost !== undefined || result.billing) {
|
|
318
392
|
const details = [
|
|
319
393
|
result.requestId ? `request ID: ${result.requestId}` : null,
|
package/dist/commands/spaces.js
CHANGED
|
@@ -1354,6 +1354,44 @@ function registerSessions(spacesCmd) {
|
|
|
1354
1354
|
handleHttp(e);
|
|
1355
1355
|
}
|
|
1356
1356
|
});
|
|
1357
|
+
sessionsCmd
|
|
1358
|
+
.command("files <id>")
|
|
1359
|
+
.description("List Space files changed by a session")
|
|
1360
|
+
.option("--limit <n>", "Maximum files to list; the server caps it", "200")
|
|
1361
|
+
.option("--json", "Output as JSON")
|
|
1362
|
+
.action(async (id, opts) => {
|
|
1363
|
+
const spaceId = await resolveSpace(spacesCmd);
|
|
1364
|
+
const limit = Number.parseInt(opts.limit, 10);
|
|
1365
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
1366
|
+
return error("Invalid limit", "--limit must be a positive integer");
|
|
1367
|
+
}
|
|
1368
|
+
const client = createClient();
|
|
1369
|
+
try {
|
|
1370
|
+
const result = await client.space(spaceId).session(id).files({ limit });
|
|
1371
|
+
if (jsonRequested(opts))
|
|
1372
|
+
return outJson(result);
|
|
1373
|
+
if (result.files.length === 0) {
|
|
1374
|
+
console.log(" (empty)");
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
table(result.files.map((file) => ({
|
|
1378
|
+
path: file.path,
|
|
1379
|
+
change: file.lastKind,
|
|
1380
|
+
count: file.changeCount,
|
|
1381
|
+
turn: file.lastTurnSequence,
|
|
1382
|
+
changedAt: file.lastChangedAt,
|
|
1383
|
+
})), [
|
|
1384
|
+
{ key: "path", label: "Path" },
|
|
1385
|
+
{ key: "change", label: "Change" },
|
|
1386
|
+
{ key: "count", label: "Count" },
|
|
1387
|
+
{ key: "turn", label: "Turn" },
|
|
1388
|
+
{ key: "changedAt", label: "Changed" },
|
|
1389
|
+
]);
|
|
1390
|
+
}
|
|
1391
|
+
catch (e) {
|
|
1392
|
+
handleHttp(e);
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1357
1395
|
sessionsCmd
|
|
1358
1396
|
.command("rename <id> <name>")
|
|
1359
1397
|
.description("Rename a session")
|
|
@@ -50,18 +50,18 @@ export async function serveRuntime(options) {
|
|
|
50
50
|
if (options.signal.aborted)
|
|
51
51
|
return;
|
|
52
52
|
log("warn", "runtime.connection_failed", { error: serializeDiagnosticError(error) });
|
|
53
|
-
outcome = "retry";
|
|
53
|
+
outcome = { kind: "retry" };
|
|
54
54
|
}
|
|
55
55
|
options.onDisconnected?.();
|
|
56
56
|
if (options.signal.aborted)
|
|
57
57
|
return;
|
|
58
|
-
if (outcome === "fatal")
|
|
59
|
-
throw new Error(
|
|
58
|
+
if (outcome.kind === "fatal")
|
|
59
|
+
throw new Error(`Runtime connection rejected (${[outcome.code, outcome.reason].filter(Boolean).join(" ")}); ${outcome.code === 4403 ? "check Space permissions" : "upgrade the CLI"}`);
|
|
60
60
|
if (readyAt && Date.now() - readyAt >= 60_000) {
|
|
61
61
|
backoff = 500;
|
|
62
62
|
attempt = 0;
|
|
63
63
|
}
|
|
64
|
-
if (outcome === "conflict") {
|
|
64
|
+
if (outcome.kind === "conflict") {
|
|
65
65
|
conflictSince ??= Date.now();
|
|
66
66
|
if (Date.now() - conflictSince >= (options.leaseConflictTimeoutMs ?? 90_000)) {
|
|
67
67
|
throw new Error("Space is already connected to another Runtime");
|
|
@@ -70,7 +70,7 @@ export async function serveRuntime(options) {
|
|
|
70
70
|
log("debug", "runtime.reconnect_scheduled", {
|
|
71
71
|
attempt: attempt || 1,
|
|
72
72
|
delayMs: backoff,
|
|
73
|
-
outcome,
|
|
73
|
+
outcome: outcome.kind,
|
|
74
74
|
});
|
|
75
75
|
await delay(backoff / 2 + Math.random() * backoff / 2, undefined, { signal: options.signal }).catch(() => undefined);
|
|
76
76
|
backoff = Math.min(30_000, backoff * 2);
|
|
@@ -118,6 +118,8 @@ async function connect(options) {
|
|
|
118
118
|
let lastHeartbeat = Date.now();
|
|
119
119
|
let connectionId = null;
|
|
120
120
|
let fatal = false;
|
|
121
|
+
let fatalCode = 0;
|
|
122
|
+
let fatalReason = "";
|
|
121
123
|
let conflict = false;
|
|
122
124
|
let unauthorized = false;
|
|
123
125
|
let readyTimer;
|
|
@@ -207,9 +209,13 @@ async function connect(options) {
|
|
|
207
209
|
socket.addEventListener("close", (event) => {
|
|
208
210
|
unauthorized = event.code === 4401;
|
|
209
211
|
fatal = [4400, 4403].includes(event.code);
|
|
212
|
+
if (fatal) {
|
|
213
|
+
fatalCode = event.code;
|
|
214
|
+
fatalReason = event.reason;
|
|
215
|
+
}
|
|
210
216
|
conflict = event.code === 4409;
|
|
211
217
|
disconnected.abort();
|
|
212
|
-
log(options.signal.aborted ? "debug" : fatal || conflict ? "error" : "warn", "runtime.websocket.closed", {
|
|
218
|
+
log(options.signal.aborted ? "debug" : fatal || conflict ? "error" : "warn", fatal ? "runtime.websocket.rejected" : "runtime.websocket.closed", {
|
|
213
219
|
code: event.code,
|
|
214
220
|
reason: event.reason,
|
|
215
221
|
durationMs: Date.now() - connectedAt,
|
|
@@ -603,5 +609,5 @@ async function connect(options) {
|
|
|
603
609
|
}
|
|
604
610
|
if (unauthorized && !options.signal.aborted)
|
|
605
611
|
await options.token(true);
|
|
606
|
-
return fatal ? "fatal" : conflict ? "conflict" : "retry";
|
|
612
|
+
return fatal ? { kind: "fatal", code: fatalCode, reason: fatalReason } : conflict ? { kind: "conflict" } : { kind: "retry" };
|
|
607
613
|
}
|
|
@@ -7,6 +7,8 @@ export type HarnessOptions = {
|
|
|
7
7
|
export declare function harnessExecutableCandidates(binary: string, cwd: string, path: string, platform?: NodeJS.Platform, pathExt?: string | undefined): string[];
|
|
8
8
|
/** Discover installed executables only; authentication/capability errors stay explicit. */
|
|
9
9
|
export declare function installedHarnesses(cwd: string, options: HarnessOptions, path?: string): Promise<("pi" | "codex")[]>;
|
|
10
|
+
/** Aborted streams leave tool calls with an empty id or name. */
|
|
11
|
+
export declare const identifiable: (value: unknown) => value is string;
|
|
10
12
|
export declare function piContent(value: unknown): ContentBlock[];
|
|
11
13
|
export declare function imageForPi(block: Extract<ContentBlock, {
|
|
12
14
|
type: "image";
|
package/dist/runtime/harness.js
CHANGED
|
@@ -35,6 +35,8 @@ export async function installedHarnesses(cwd, options, path = process.env.PATH ?
|
|
|
35
35
|
}
|
|
36
36
|
const array = (value) => Array.isArray(value) ? value : [];
|
|
37
37
|
const text = (value) => typeof value === "string" ? value : "";
|
|
38
|
+
/** Aborted streams leave tool calls with an empty id or name. */
|
|
39
|
+
export const identifiable = (value) => typeof value === "string" && value.length > 0;
|
|
38
40
|
export function piContent(value) {
|
|
39
41
|
return array(value).flatMap((entry) => {
|
|
40
42
|
const block = record(entry);
|
|
@@ -43,7 +45,7 @@ export function piContent(value) {
|
|
|
43
45
|
if (block.type === "thinking")
|
|
44
46
|
return [{ type: "thinking", thinking: text(block.thinking), ...(typeof (block.thinkingSignature ?? block.signature) === "string" ? { signature: String(block.thinkingSignature ?? block.signature) } : {}) }];
|
|
45
47
|
if (block.type === "toolCall")
|
|
46
|
-
return [{ type: "tool_use", id:
|
|
48
|
+
return identifiable(block.id) && identifiable(block.name) ? [{ type: "tool_use", id: block.id, name: block.name, input: record(block.arguments) }] : [];
|
|
47
49
|
if (block.type === "image")
|
|
48
50
|
return [{ type: "image", source: { type: "base64", data: text(block.data), media_type: text(block.mimeType) } }];
|
|
49
51
|
return [{ type: "text", text: JSON.stringify(block) }];
|
|
@@ -105,7 +107,9 @@ export function codexItemContent(item) {
|
|
|
105
107
|
return [];
|
|
106
108
|
if (item.type === "contextCompaction")
|
|
107
109
|
return [{ type: "system_note", note_type: "compacted", text: "Context compacted" }];
|
|
108
|
-
const id =
|
|
110
|
+
const id = item.id;
|
|
111
|
+
if (!identifiable(id))
|
|
112
|
+
return [];
|
|
109
113
|
const name = item.type === "commandExecution" ? "bash" : item.type === "fileChange" ? "apply_patch" : text(item.tool) || text(item.type);
|
|
110
114
|
const input = item.type === "commandExecution" ? { command: item.command, cwd: item.cwd } : item.type === "fileChange" ? { changes: item.changes } : record(item.arguments);
|
|
111
115
|
return [
|
|
@@ -13,6 +13,10 @@ export declare class JsonLineDecoder {
|
|
|
13
13
|
push(chunk: Buffer): void;
|
|
14
14
|
end(): void;
|
|
15
15
|
}
|
|
16
|
+
/** Anything surfacing while the host closes the process; not a failure. */
|
|
17
|
+
export declare class RpcProcessClosedError extends Error {
|
|
18
|
+
constructor();
|
|
19
|
+
}
|
|
16
20
|
export declare function harnessEnvironment(): NodeJS.ProcessEnv;
|
|
17
21
|
export declare class JsonRpcProcess {
|
|
18
22
|
private mode;
|
package/dist/runtime/json-rpc.js
CHANGED
|
@@ -44,6 +44,10 @@ export class JsonLineDecoder {
|
|
|
44
44
|
}
|
|
45
45
|
end() { this.append(this.decoder.end()); this.emit(); }
|
|
46
46
|
}
|
|
47
|
+
/** Anything surfacing while the host closes the process; not a failure. */
|
|
48
|
+
export class RpcProcessClosedError extends Error {
|
|
49
|
+
constructor() { super("RPC process closed"); this.name = "RpcProcessClosedError"; }
|
|
50
|
+
}
|
|
47
51
|
export function harnessEnvironment() {
|
|
48
52
|
return Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("COHUB_") && !["WORKER_SECRET", "DATABASE_URL", "REDIS_URL"].includes(key)));
|
|
49
53
|
}
|
|
@@ -81,12 +85,12 @@ export class JsonRpcProcess {
|
|
|
81
85
|
this.child.stderr.on("data", (chunk) => { this.stderr = (this.stderr + chunk.toString()).slice(-8192); });
|
|
82
86
|
this.child.on("error", (error) => this.fail(error));
|
|
83
87
|
this.child.stdin.on("error", (error) => this.fail(error));
|
|
84
|
-
this.child.once("close", (code) => this.fail(new Error(`${binary} exited (${code}): ${this.stderr}`)));
|
|
88
|
+
this.child.once("close", (code, signal) => this.fail(new Error(signal ? `${binary} terminated by ${signal}: ${this.stderr}` : `${binary} exited (${code}): ${this.stderr}`)));
|
|
85
89
|
}
|
|
86
90
|
fail(value) {
|
|
87
91
|
if (this.failure)
|
|
88
92
|
return;
|
|
89
|
-
this.failure = value instanceof Error ? value : new Error(String(value));
|
|
93
|
+
this.failure = this.closing ? new RpcProcessClosedError() : value instanceof Error ? value : new Error(String(value));
|
|
90
94
|
for (const pending of this.pending.values()) {
|
|
91
95
|
clearTimeout(pending.timer);
|
|
92
96
|
pending.reject(this.failure);
|
|
@@ -168,7 +172,7 @@ export class JsonRpcProcess {
|
|
|
168
172
|
this.child.stdin.destroy();
|
|
169
173
|
this.child.stdout.destroy();
|
|
170
174
|
this.child.stderr.destroy();
|
|
171
|
-
this.fail(new
|
|
175
|
+
this.fail(new RpcProcessClosedError());
|
|
172
176
|
this.listeners.clear();
|
|
173
177
|
this.failureListeners.clear();
|
|
174
178
|
this.timeoutListeners.clear();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { imageForPi } from "../harness.js";
|
|
2
|
-
import { JsonRpcProcess, record } from "../json-rpc.js";
|
|
2
|
+
import { JsonRpcProcess, record, RpcProcessClosedError } from "../json-rpc.js";
|
|
3
3
|
import { ProcessCleanupUncertainError } from "../process-group.js";
|
|
4
4
|
import { serializeDiagnosticError } from "../diagnostics.js";
|
|
5
5
|
import { openPrivateServer } from "./codex-link.js";
|
|
@@ -322,7 +322,10 @@ async function codexPrompt(input) {
|
|
|
322
322
|
function watchHost(host, diagnostics, context) {
|
|
323
323
|
if (!diagnostics)
|
|
324
324
|
return;
|
|
325
|
-
host.onFailure((error) =>
|
|
325
|
+
host.onFailure((error) => {
|
|
326
|
+
if (!(error instanceof RpcProcessClosedError))
|
|
327
|
+
diagnostics.log("error", "harness.rpc_process_failed", { error: serializeDiagnosticError(error) }, { ...context, component: "harness" });
|
|
328
|
+
});
|
|
326
329
|
host.onTimeout((method, timeoutMs) => diagnostics.log("error", "harness.rpc_timeout", { method, timeoutMs }, { ...context, component: "harness" }));
|
|
327
330
|
}
|
|
328
331
|
/** Close a harness process Cohub started. An unconfirmable cleanup marks the result uncertain; it never masks it. */
|
|
@@ -6,7 +6,7 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { basename, join } from "node:path";
|
|
7
7
|
import { createZstdDecompress } from "node:zlib";
|
|
8
8
|
import { pipeline } from "node:stream/promises";
|
|
9
|
-
import { piContent } from "../harness.js";
|
|
9
|
+
import { identifiable, piContent } from "../harness.js";
|
|
10
10
|
import { codexTokenTotals, codexUsage } from "../codex-usage.js";
|
|
11
11
|
import { record } from "../json-rpc.js";
|
|
12
12
|
const text = (value) => typeof value === "string" ? value : "";
|
|
@@ -265,6 +265,7 @@ function parsePiTranscript(lines, options) {
|
|
|
265
265
|
let cloudSessionId = text(record(header.cohub).sessionId) || text(record(header.affinity).sessionId);
|
|
266
266
|
let current = null;
|
|
267
267
|
let messages = [];
|
|
268
|
+
const calls = new Map(); // tool call id → owning message
|
|
268
269
|
let pendingMarker = null;
|
|
269
270
|
let completedAt = iso(header.timestamp);
|
|
270
271
|
const finish = (settled) => {
|
|
@@ -310,16 +311,19 @@ function parsePiTranscript(lines, options) {
|
|
|
310
311
|
cloudSessionId = text(record(message.meta).sourceSessionId) || cloudSessionId;
|
|
311
312
|
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 };
|
|
312
313
|
messages = current.messages;
|
|
314
|
+
calls.clear();
|
|
313
315
|
}
|
|
314
316
|
else if (current && message.role === "assistant") {
|
|
315
|
-
|
|
316
|
-
usage: record(message.usage), stopReason: text(message.stopReason) || null, errorMessage: text(message.errorMessage) || null }
|
|
317
|
+
const assistant = { content: piContent(message.content), provider: text(message.provider) || null, model: text(message.model) || null,
|
|
318
|
+
usage: record(message.usage), stopReason: text(message.stopReason) || null, errorMessage: text(message.errorMessage) || null };
|
|
319
|
+
messages.push(assistant);
|
|
320
|
+
for (const block of assistant.content)
|
|
321
|
+
if (block.type === "tool_use")
|
|
322
|
+
calls.set(block.id, assistant);
|
|
317
323
|
}
|
|
318
324
|
else if (current && message.role === "toolResult") {
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
throw new Error("Pi tool result has no assistant Turn");
|
|
322
|
-
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) });
|
|
325
|
+
const id = text(message.toolCallId);
|
|
326
|
+
calls.get(id)?.content.push({ type: "tool_result", tool_use_id: id, content: typeof message.content === "string" ? message.content : piContent(message.content), is_error: Boolean(message.isError) });
|
|
323
327
|
}
|
|
324
328
|
if (current) {
|
|
325
329
|
current.endBytes = line.endBytes;
|
|
@@ -390,6 +394,7 @@ function parseCodexLines(lines, segment) {
|
|
|
390
394
|
const turns = [];
|
|
391
395
|
let current = null;
|
|
392
396
|
let messages = [];
|
|
397
|
+
const calls = new Map(); // tool call id → owning message
|
|
393
398
|
let model = null;
|
|
394
399
|
let cloudSessionId = text(record(metadata.cohub).sessionId);
|
|
395
400
|
let provider = text(metadata.model_provider) || null;
|
|
@@ -417,6 +422,7 @@ function parseCodexLines(lines, segment) {
|
|
|
417
422
|
throw new Error("Codex Turn identity is missing");
|
|
418
423
|
messages = current.messages;
|
|
419
424
|
userFromResponse = false;
|
|
425
|
+
calls.clear();
|
|
420
426
|
}
|
|
421
427
|
if (entry.type === "token_usage_record") {
|
|
422
428
|
const usage = record(payload.turn_token_usage);
|
|
@@ -469,10 +475,15 @@ function parseCodexLines(lines, segment) {
|
|
|
469
475
|
catch {
|
|
470
476
|
input = { raw: payload.arguments };
|
|
471
477
|
}
|
|
472
|
-
|
|
478
|
+
if (identifiable(payload.call_id) && identifiable(payload.name)) {
|
|
479
|
+
const owner = assistant();
|
|
480
|
+
owner.content.push({ type: "tool_use", id: payload.call_id, name: payload.name, input });
|
|
481
|
+
calls.set(payload.call_id, owner);
|
|
482
|
+
}
|
|
473
483
|
}
|
|
474
484
|
else if (["function_call_output", "custom_tool_call_output"].includes(text(payload.type))) {
|
|
475
|
-
|
|
485
|
+
const id = text(payload.call_id);
|
|
486
|
+
calls.get(id)?.content.push({ type: "tool_result", tool_use_id: id, content: typeof payload.output === "string" ? payload.output : JSON.stringify(payload.output ?? null) });
|
|
476
487
|
}
|
|
477
488
|
}
|
|
478
489
|
if (entry.type === "event_msg" && payload.type === "item_completed") {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { codexItemContent, piContent } from "../harness.js";
|
|
1
|
+
import { codexItemContent, identifiable, piContent } from "../harness.js";
|
|
2
2
|
import { codexTokenTotals, codexUsage, subtractCodexTokens } from "../codex-usage.js";
|
|
3
3
|
import { record } from "../json-rpc.js";
|
|
4
4
|
const text = (value) => typeof value === "string" ? value : "";
|
|
5
5
|
const list = (value) => Array.isArray(value) ? value : [];
|
|
6
|
+
const hasToolCall = (content, id) => content.some((block) => block.type === "tool_use" && block.id === id);
|
|
6
7
|
/** Pi emits the same event shapes to its extensions as over RPC. */
|
|
7
8
|
export function piTranslator(emit) {
|
|
8
9
|
let ordinal = -1;
|
|
@@ -22,18 +23,18 @@ export function piTranslator(emit) {
|
|
|
22
23
|
current = piContent(message.content);
|
|
23
24
|
emit({ type: "content.replace", ordinal, content: current });
|
|
24
25
|
}
|
|
26
|
+
const toolCallId = text(event.toolCallId);
|
|
25
27
|
if (type === "tool_execution_start") {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
if (identifiable(toolCallId) && identifiable(event.toolName) && !hasToolCall(current, toolCallId)) {
|
|
29
|
+
current.push({ type: "tool_use", id: toolCallId, name: event.toolName, input: record(event.args), _meta: { toolStatus: "running" } });
|
|
30
|
+
emit({ type: "content.replace", ordinal, content: [...current] });
|
|
31
|
+
}
|
|
30
32
|
}
|
|
31
|
-
if (type === "tool_execution_update" || type === "tool_execution_end") {
|
|
32
|
-
const id = text(event.toolCallId);
|
|
33
|
+
if ((type === "tool_execution_update" || type === "tool_execution_end") && hasToolCall(current, toolCallId)) {
|
|
33
34
|
const raw = type === "tool_execution_end" ? event.result : event.partialResult;
|
|
34
35
|
const resultContent = typeof raw === "string" ? raw : piContent(record(raw).content);
|
|
35
|
-
current = current.filter((block) => block.type !== "tool_result" || block.tool_use_id !==
|
|
36
|
-
current.push({ type: "tool_result", tool_use_id:
|
|
36
|
+
current = current.filter((block) => block.type !== "tool_result" || block.tool_use_id !== toolCallId);
|
|
37
|
+
current.push({ type: "tool_result", tool_use_id: toolCallId, content: resultContent, is_error: Boolean(event.isError), _meta: { toolStatus: type === "tool_execution_end" ? "done" : "running" } });
|
|
37
38
|
emit({ type: "content.replace", ordinal, content: [...current] });
|
|
38
39
|
}
|
|
39
40
|
if (type === "message_update") {
|
|
@@ -45,7 +46,9 @@ export function piTranslator(emit) {
|
|
|
45
46
|
const content = piContent(message.content);
|
|
46
47
|
for (const value of list(event.toolResults)) {
|
|
47
48
|
const result = record(value);
|
|
48
|
-
|
|
49
|
+
const id = text(result.toolCallId);
|
|
50
|
+
if (hasToolCall(content, id))
|
|
51
|
+
content.push({ type: "tool_result", tool_use_id: id, content: typeof result.content === "string" ? result.content : piContent(result.content), is_error: Boolean(result.isError) });
|
|
49
52
|
}
|
|
50
53
|
const stopReason = text(message.stopReason);
|
|
51
54
|
last = { ordinal: Math.max(0, ordinal), content, provider: text(message.provider) || null, model: text(message.model) || null, usage: message.usage, stopReason: stopReason === "toolUse" ? "tool_use" : stopReason || "stop", errorMessage: text(message.errorMessage) || null };
|
|
@@ -6,6 +6,7 @@ const messages = {
|
|
|
6
6
|
"runtime.ready": "Harness connected",
|
|
7
7
|
"runtime.available": "Runtime ready",
|
|
8
8
|
"runtime.websocket.closed": "Connection lost; reconnecting",
|
|
9
|
+
"runtime.websocket.rejected": "Connection rejected; not reconnecting",
|
|
9
10
|
"runtime.heartbeat_timeout": "Connection timed out; reconnecting",
|
|
10
11
|
"runtime.auth_token_failed": "Cannot obtain credentials; retrying",
|
|
11
12
|
"runtime.auth_required": "Sign in with cohub auth login",
|
|
@@ -19,8 +19,8 @@ async function probeLinux(pid) {
|
|
|
19
19
|
members.push({ pid: Number(entry), state: state ?? "U" });
|
|
20
20
|
}
|
|
21
21
|
catch (error) {
|
|
22
|
-
//
|
|
23
|
-
if (error.code
|
|
22
|
+
// Exited between readdir and read.
|
|
23
|
+
if (!["ENOENT", "ESRCH"].includes(error.code ?? ""))
|
|
24
24
|
throw error;
|
|
25
25
|
}
|
|
26
26
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub-cli",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.4.0",
|
|
4
4
|
"description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"pixi.js": "^8.20.1",
|
|
24
24
|
"sharp": "^0.35.4",
|
|
25
25
|
"ws": "^8.21.3",
|
|
26
|
-
"@neta-art/cohub": "8.
|
|
26
|
+
"@neta-art/cohub": "8.23.0"
|
|
27
27
|
},
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|