@neta-art/cohub-cli 8.3.0 → 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 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
- async function contentFromPathOrUrl(type, rawValue) {
77
- const { value, role } = await parseMediaInput(type, rawValue);
78
- const meta = role ? { role } : undefined;
79
- if (/^https?:\/\//.test(value)) {
80
- return { type, source: { type: "url", url: value }, ...(meta ? { meta } : {}) };
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
- const mediaType = mimeByExt[extname(value).toLowerCase()] ?? "application/octet-stream";
84
- return {
85
- type,
86
- source: { type: "base64", mediaType, data: data.toString("base64") },
87
- ...(meta ? { meta } : {}),
88
- };
98
+ return block({
99
+ type: "base64",
100
+ mediaType: mediaType ?? "application/octet-stream",
101
+ data: data.toString("base64"),
102
+ });
89
103
  }
90
- function validateMediaRoleModes(content) {
91
- const roles = content.map(metaRole).filter((role) => Boolean(role));
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
- function printGeneration(output) {
178
- for (const block of output) {
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
- console.log(`${formatOutputLabel(block)}: ${block.source.url}`);
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 content = [{ type: "text", text: prompt }];
263
- content.push(...await Promise.all(opts.image.map((value) => contentFromPathOrUrl("image", value))));
264
- content.push(...await Promise.all(opts.video.map((value) => contentFromPathOrUrl("video", value))));
265
- content.push(...await Promise.all(opts.audio.map((value) => contentFromPathOrUrl("audio", value))));
266
- validateMediaRoleModes(content);
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: envValue("COHUB_SESSION_ID"),
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 = opts.output ? await saveOutputs(result.output, opts.output) : [];
314
- if (jsonRequested(opts))
315
- return outJson(savedPaths.length > 0 ? { ...result, taskRunId: created.taskRunId, savedPaths } : { ...result, taskRunId: created.taskRunId });
316
- printGeneration(result.output);
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,
@@ -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("Runtime connection rejected; check permissions or upgrade the CLI");
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";
@@ -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: text(block.id), name: text(block.name), input: record(block.arguments) }];
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 = text(item.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;
@@ -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 Error("RPC process closed"));
175
+ this.fail(new RpcProcessClosedError());
172
176
  this.listeners.clear();
173
177
  this.failureListeners.clear();
174
178
  this.timeoutListeners.clear();
@@ -10,7 +10,7 @@ import { createClient } from "../client.js";
10
10
  import { currentIdentityKey, explicitSpace } from "../space.js";
11
11
  import { canonicalRuntimeRoot, getRuntimeSpaceBinding, resolveRuntimeSpace } from "./space-binding.js";
12
12
  import { controlRuntimeInstance, requestRuntimeInstance, runtimeInstanceDirectory } from "./instance.js";
13
- import { ensureNativeSync } from "./native/attach.js";
13
+ import { ensureNativeSync, repairIntegrations } from "./native/attach.js";
14
14
  import { createDiagnosticConsole, printRuntimeSummary, runtimeWebUrl } from "./presentation.js";
15
15
  import { runRuntime } from "./supervisor.js";
16
16
  export const resolveLocalSpaceName = (root, name) => name?.trim() || basename(root) || "local-space";
@@ -144,6 +144,7 @@ export async function runtimeUp(program, dir, options) {
144
144
  if (createNew && binding && await requestRuntimeInstance(runtimeInstanceDirectory(identity, binding.spaceId))) {
145
145
  throw new Error("Stop the existing Runtime before rebinding this directory");
146
146
  }
147
+ await repairIntegrations(options.pi);
147
148
  // Fail local preflight before creating remote state; the worker reuses this catalog.
148
149
  const capabilities = await discoverHarnesses(harnesses, options, root);
149
150
  const client = createClient();
@@ -30,4 +30,9 @@ export declare function nativeSyncSatisfied(config: NativeConfig | null, input:
30
30
  * the user agrees; a failure here never blocks the Runtime, it only leaves sessions read-only.
31
31
  */
32
32
  export declare function ensureNativeSync(input: Input, log?: (line: string) => void, ask?: Ask): Promise<NativeSyncReport>;
33
+ /**
34
+ * Before any harness starts, repair what earlier releases installed: an outdated Pi extension can keep
35
+ * Pi from starting, and legacy Codex hooks run a script that is gone. Both had consent back then.
36
+ */
37
+ export declare function repairIntegrations(piBinary: string | undefined, log?: (line: string) => void): Promise<void>;
33
38
  export {};
@@ -75,12 +75,22 @@ export async function ensureNativeSync(input, log = (line) => process.stderr.wri
75
75
  }
76
76
  return { enabled: true, harnesses, codexShared, importHistory };
77
77
  }
78
- /** Keep integrations current on every `up`. Pi without the extension still syncs, read-only. */
78
+ /** Keep integrations current on every `up`. */
79
79
  async function maintain(config, log, piBinary, announce = false) {
80
- if (config.harnesses.includes("codex"))
81
- await removeLegacyCodexHooks().catch(() => undefined);
82
- if (!config.harnesses.includes("pi"))
83
- return;
80
+ if (config.harnesses.includes("pi"))
81
+ await maintainPiExtension(piBinary, log, announce);
82
+ }
83
+ /**
84
+ * Before any harness starts, repair what earlier releases installed: an outdated Pi extension can keep
85
+ * Pi from starting, and legacy Codex hooks run a script that is gone. Both had consent back then.
86
+ */
87
+ export async function repairIntegrations(piBinary, log = (line) => process.stderr.write(line)) {
88
+ await removeLegacyCodexHooks().catch(() => undefined);
89
+ if (await piExtensionState().catch(() => null) === "outdated")
90
+ await maintainPiExtension(piBinary, log);
91
+ }
92
+ /** Pi without the extension still syncs, read-only. */
93
+ async function maintainPiExtension(piBinary, log, announce = false) {
84
94
  const version = await harnessVersion(piBinary || "pi");
85
95
  if (!atLeast(version, PI_MIN_VERSION)) {
86
96
  log(`Pi ${formatVersion(version)} predates the Cohub extension (${PI_MIN_VERSION.join(".")}+); Pi chats sync read-only\n`);
@@ -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) => diagnostics.log("error", "harness.rpc_process_failed", { error: serializeDiagnosticError(error) }, { ...context, component: "harness" }));
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. */
@@ -7,19 +7,23 @@ import { withRuntimeSpaceBindingsLock } from "../space-binding.js";
7
7
  const missing = (error) => error.code === "ENOENT";
8
8
  /** First line of the installed extension; a file without it belongs to someone else. */
9
9
  const PI_EXTENSION_HEADER = "// Cohub control extension for Pi. Managed by `cohub runtime`; local edits are replaced on upgrade.\n";
10
- /** Earlier releases installed a re-export under this name; it points at a module that no longer exists. */
11
- const LEGACY_PI_EXTENSION_HEADER = "// Cohub native Turn sync\n";
10
+ /** CLI 8.0–8.2 installed a re-export under this comment; its module is gone, so Pi fails to start while it remains. */
11
+ const LEGACY_PI_EXTENSION_HEADER = "// Cohub native Turn sync";
12
12
  /** Earlier releases installed Codex hooks; they are removed on upgrade. */
13
13
  const LEGACY_CODEX_HOOKS = /\n*# BEGIN COHUB NATIVE SYNC\n[\s\S]*?# END COHUB NATIVE SYNC\n?/;
14
14
  export const piAgentDirectory = () => process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
15
15
  const codexConfigPath = () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "config.toml");
16
16
  /** The extension shipped with this CLI: one self-contained module, loadable by Pi as is. */
17
17
  export const piExtensionSource = () => fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./pi-extension.ts" : "./pi-extension.js", import.meta.url));
18
- const piExtensionTarget = (source) => join(piAgentDirectory(), "extensions", `cohub${extname(source)}`);
18
+ const piExtensionPath = (extension) => join(piAgentDirectory(), "extensions", `cohub${extension}`);
19
+ const readOptional = (path) => readFile(path, "utf8").catch((error) => { if (missing(error))
20
+ return null; throw error; });
21
+ const isLegacy = (text) => Boolean(text?.startsWith(LEGACY_PI_EXTENSION_HEADER));
19
22
  export async function piExtensionState() {
20
23
  const source = piExtensionSource();
21
- const [expected, current] = await Promise.all([readFile(source, "utf8"), readFile(piExtensionTarget(source), "utf8").catch((error) => { if (missing(error))
22
- return null; throw error; })]);
24
+ const [expected, current, legacy] = await Promise.all([readFile(source, "utf8"), readOptional(piExtensionPath(extname(source))), readOptional(piExtensionPath(".ts"))]);
25
+ if (isLegacy(legacy))
26
+ return "outdated";
23
27
  if (current === null)
24
28
  return "missing";
25
29
  if (current === PI_EXTENSION_HEADER + expected)
@@ -30,22 +34,26 @@ export async function piExtensionState() {
30
34
  export async function installPiExtension() {
31
35
  const source = piExtensionSource();
32
36
  const content = PI_EXTENSION_HEADER + await readFile(source, "utf8");
33
- const target = piExtensionTarget(source);
37
+ const target = piExtensionPath(extname(source));
34
38
  await writeManaged(target, (existing) => {
35
- if (existing !== null && !existing.startsWith(PI_EXTENSION_HEADER))
39
+ if (existing !== null && !existing.startsWith(PI_EXTENSION_HEADER) && !isLegacy(existing))
36
40
  throw new Error(`${target} exists and is not managed by Cohub; move it aside, then retry`);
37
41
  return content;
38
42
  }, { backup: false });
39
- for (const legacy of ["cohub.ts", "cohub.js"].map((name) => join(dirname(target), name)).filter((path) => path !== target)) {
40
- const text = await readFile(legacy, "utf8").catch(() => null);
41
- if (text?.startsWith(LEGACY_PI_EXTENSION_HEADER) || text?.startsWith(PI_EXTENSION_HEADER))
42
- await rm(legacy, { force: true });
43
+ // Pi loads every file here: drop a legacy or managed copy under the other extension.
44
+ for (const other of [".ts", ".js"].map(piExtensionPath).filter((path) => path !== target)) {
45
+ const text = await readOptional(other).catch(() => null);
46
+ if (isLegacy(text) || text?.startsWith(PI_EXTENSION_HEADER))
47
+ await rm(other, { force: true });
43
48
  }
44
49
  return "installed";
45
50
  }
46
51
  /** Remove the Codex hooks earlier releases installed; Cohub now reads Codex's own files instead. */
47
52
  export async function removeLegacyCodexHooks() {
48
53
  const path = codexConfigPath();
54
+ // Read first: the lock would create `~/.codex` for someone who never used Codex.
55
+ if (!LEGACY_CODEX_HOOKS.test(await readOptional(path) ?? ""))
56
+ return false;
49
57
  let removed = false;
50
58
  await writeManaged(path, (existing) => {
51
59
  if (existing === null || !LEGACY_CODEX_HOOKS.test(existing))
@@ -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
- messages.push({ content: piContent(message.content), provider: text(message.provider) || null, model: text(message.model) || null,
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 assistant = messages.at(-1);
320
- if (!assistant)
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
- assistant().content.push({ type: "tool_use", id: text(payload.call_id), name: text(payload.name), input });
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
- 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) });
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
- const id = text(event.toolCallId);
27
- if (!current.some((block) => block.type === "tool_use" && block.id === id))
28
- current.push({ type: "tool_use", id, name: text(event.toolName), input: record(event.args), _meta: { toolStatus: "running" } });
29
- emit({ type: "content.replace", ordinal, content: [...current] });
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 !== id);
36
- current.push({ type: "tool_result", tool_use_id: id, content: resultContent, is_error: Boolean(event.isError), _meta: { toolStatus: type === "tool_execution_end" ? "done" : "running" } });
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
- content.push({ type: "tool_result", tool_use_id: text(result.toolCallId), content: typeof result.content === "string" ? result.content : piContent(result.content), is_error: Boolean(result.isError) });
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
- // The member exited between readdir and read; it cannot execute anymore.
23
- if (error.code !== "ENOENT")
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.0",
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.22.0"
26
+ "@neta-art/cohub": "8.23.0"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"