@neta-art/cohub-cli 10.8.1 → 10.8.3

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.
@@ -32153,6 +32153,10 @@ async function loadCanvasModule() {
32153
32153
  throw new Error("Board image export needs the optional '@napi-rs/canvas' package. Install it with: npm i @napi-rs/canvas", { cause });
32154
32154
  }
32155
32155
  }
32156
+ function installDOMMatrix(canvasModule) {
32157
+ const globals = globalThis;
32158
+ globals.DOMMatrix ??= canvasModule.DOMMatrix;
32159
+ }
32156
32160
  function installAnimationFrameShim() {
32157
32161
  const globals = globalThis;
32158
32162
  if (globals.requestAnimationFrame) return () => {};
@@ -32207,6 +32211,7 @@ async function createNodeBoardRenderer(options = {}) {
32207
32211
  throw new Error("XML parsing is not available in Node board export.");
32208
32212
  }
32209
32213
  });
32214
+ installDOMMatrix(canvasModule);
32210
32215
  installBoardTextMeasurement();
32211
32216
  const restoreAnimationFrame = installAnimationFrameShim();
32212
32217
  const renderer = new CanvasRenderer();
@@ -4,7 +4,7 @@ import { projectNativeSession } from "@neta-art/cohub";
4
4
  import { JsonRpcProcess, record } from "../json-rpc.js";
5
5
  import { codexModelCatalog } from "../model-catalog.js";
6
6
  import { describeError } from "../../network-error.js";
7
- import { openPrivateServer } from "../native/codex-link.js";
7
+ import { CODEX_UNLOAD_DELAY_SECS, openPrivateServer, resumeThread } from "../native/codex-link.js";
8
8
  import { ArchiveNotRestorableError, copyNativeArchive } from "../native/archive.js";
9
9
  import { codexArchivedSessionsDirectory, codexRolloutBytes, codexRolloutIdOf, codexSessionsDirectory, ensurePlainCodexRollout, readFirstRecord, readNativeTranscript, } from "../native/transcript.js";
10
10
  import { codexTranslator } from "../native/translate.js";
@@ -41,6 +41,7 @@ async function runCodex(executor, turn) {
41
41
  let channel = shared;
42
42
  let message = { ordinal: 0, content: [] };
43
43
  let begun = false;
44
+ let joined = null;
44
45
  try {
45
46
  if (!channel) {
46
47
  host = new JsonRpcProcess(...harnessCommand(executor.executables.codex || "codex", ["app-server", "--listen", "stdio://"], executor.cwd), executor.cwd, "jsonrpc", turnEnvironment(input));
@@ -54,13 +55,17 @@ async function runCodex(executor, turn) {
54
55
  ...(input.provider && input.provider !== "codex" ? { modelProvider: input.provider } : {}),
55
56
  ...(input.accessMode === "read_only" ? { sandbox: "read-only" } : {}),
56
57
  // Tools of a shared server otherwise see the environment of whoever started that server.
57
- config: { "shell_environment_policy.set.COHUB_SPACE_ID": input.spaceId, "shell_environment_policy.set.COHUB_SESSION_ID": input.sessionId },
58
+ config: {
59
+ "shell_environment_policy.set.COHUB_SPACE_ID": input.spaceId, "shell_environment_policy.set.COHUB_SESSION_ID": input.sessionId,
60
+ ...(codex.shared ? { thread_unload_delay_secs: CODEX_UNLOAD_DELAY_SECS } : {}),
61
+ },
58
62
  };
59
63
  const existing = { ...threadOptions, threadId: session.nativeSessionId, path: session.path, excludeTurns: true };
60
64
  const opened = prepared.resume === "new" ? await codex.request("thread/start", threadOptions)
61
65
  : prepared.resume === "restored" ? await codex.request("thread/fork", existing)
62
- : await codex.request("thread/resume", existing);
66
+ : await resumeThread(codex, existing);
63
67
  const thread = record(opened.thread);
68
+ joined = text(thread.id) || null;
64
69
  if (typeof thread.id !== "string" || typeof thread.path !== "string")
65
70
  throw new Error("Codex did not provide a durable native thread");
66
71
  const threadId = thread.id;
@@ -143,6 +148,10 @@ async function runCodex(executor, turn) {
143
148
  }
144
149
  message = { ...message, stopReason: signal.aborted ? "aborted" : "error", errorMessage: signal.aborted ? null : describeError(error) };
145
150
  }
151
+ finally {
152
+ if (joined && channel?.shared)
153
+ await executor.codex.release(joined);
154
+ }
146
155
  return { session, resume: prepared.resume, message, host };
147
156
  }
148
157
  async function codexPrompt(input) {
@@ -29,22 +29,21 @@ export async function ensureNativeSync(input, log = (line) => process.stderr.wri
29
29
  return { enabled: false, harnesses: [], codexShared: false, importHistory: false, note: "unsupported" };
30
30
  const stateRoot = runtimeStateRoot(input.spaceId);
31
31
  const previous = await readNativeConfig(stateRoot, input.identity).catch(() => null);
32
- if (previous && nativeSyncSatisfied(previous, input)) {
33
- await maintain(previous, log, input.executables?.pi);
34
- return { enabled: true, harnesses: previous.harnesses, codexShared: previous.codexShared, importHistory: false };
35
- }
32
+ const alreadySynced = previous && nativeSyncSatisfied(previous, input);
36
33
  const interactive = process.stdin.isTTY === true;
37
34
  const confirm = async (question) => input.yes ? true : interactive ? await ask(question) : false;
38
- if (!input.yes && !interactive) {
39
- log("Native sync off · rerun interactively or with --yes to enable\n");
40
- return { enabled: false, harnesses: input.harnesses, codexShared: false, importHistory: false, note: "non-interactive" };
41
- }
42
- const names = input.harnesses.map((harness) => LOCAL_HARNESS_SPECS[harness].label).join(" and ");
43
- if (!await confirm(`Sync ${names} chats in this folder to this Space so they continue on the web? Their history may contain secrets. [Y/n] `)) {
44
- return { enabled: false, harnesses: input.harnesses, codexShared: false, importHistory: false, note: "declined" };
35
+ if (!alreadySynced) {
36
+ if (!input.yes && !interactive) {
37
+ log("Native sync off · rerun interactively or with --yes to enable\n");
38
+ return { enabled: false, harnesses: input.harnesses, codexShared: false, importHistory: false, note: "non-interactive" };
39
+ }
40
+ const names = input.harnesses.map((harness) => LOCAL_HARNESS_SPECS[harness].label).join(" and ");
41
+ if (!await confirm(`Sync ${names} chats in this folder to this Space so they continue on the web? Their history may contain secrets. [Y/n] `)) {
42
+ return { enabled: false, harnesses: input.harnesses, codexShared: false, importHistory: false, note: "declined" };
43
+ }
45
44
  }
46
45
  let codexShared = previous?.codexShared ?? false;
47
- if (input.harnesses.includes("codex") && !codexShared) {
46
+ if (input.harnesses.includes("codex") && !codexShared && (input.yes || interactive)) {
48
47
  const version = await harnessVersion(input.executables?.codex || "codex");
49
48
  if (!sharedServerAvailable) {
50
49
  log("Shared Codex sessions are unavailable on Windows; terminal Codex chats sync read-only\n");
@@ -56,6 +55,16 @@ export async function ensureNativeSync(input, log = (line) => process.stderr.wri
56
55
  codexShared = await confirm("Let terminal Codex and the web drive the same live sessions? Cohub starts Codex's shared app-server, which keeps running in the background. [Y/n] ");
57
56
  }
58
57
  }
58
+ if (previous && alreadySynced) {
59
+ if (codexShared !== previous.codexShared) {
60
+ await writeNativeConfig(stateRoot, { ...previous, codexShared }).catch((error) => {
61
+ log(`Shared Codex sessions unavailable; Runtime continues without them — ${serializeDiagnosticError(error).message}\n`);
62
+ codexShared = previous.codexShared;
63
+ });
64
+ }
65
+ await maintain(previous, log, input.executables?.pi);
66
+ return { enabled: true, harnesses: previous.harnesses, codexShared, importHistory: false };
67
+ }
59
68
  const harnesses = [...new Set([...(previous?.root === input.root ? previous.harnesses : []), ...input.harnesses])];
60
69
  const config = {
61
70
  version: 2, identity: input.identity, spaceId: input.spaceId, root: input.root, harnesses,
@@ -2,6 +2,11 @@ import { type JsonRpcProcess, type JsonRecord } from "../json-rpc.js";
2
2
  import { type Version } from "./version.js";
3
3
  /** First Codex release whose terminal client attaches to a shared app-server by default. */
4
4
  export declare const CODEX_SHARED_MIN_VERSION: Version;
5
+ /**
6
+ * A loaded thread holds Codex's per-thread writer lock, which Codex Desktop and other app-servers
7
+ * need to continue it. Long enough for an immediate follow-up Turn; honoured from Codex 0.164.
8
+ */
9
+ export declare const CODEX_UNLOAD_DELAY_SECS = 10;
5
10
  export type CodexEvent = {
6
11
  method: string;
7
12
  params: JsonRecord;
@@ -32,11 +37,17 @@ export declare class CodexShared {
32
37
  private opening;
33
38
  private ensured;
34
39
  private readonly connectListeners;
40
+ private readonly releaseListeners;
35
41
  readonly driving: Set<string>;
36
42
  constructor(binary: string, cwd: string, enabled: boolean);
37
43
  get(): Promise<CodexChannel | null>;
38
44
  onConnect(listener: (channel: CodexChannel) => void): () => void;
45
+ onRelease(listener: (threadId: string) => void): () => void;
46
+ /** Drop this connection's subscription so an idle thread can unload and free its writer lock. */
47
+ release(threadId: string): Promise<void>;
39
48
  join(threadId: string): Promise<JsonRecord | null>;
40
49
  interrupt(threadId: string, turnId: string): Promise<boolean>;
41
50
  close(): Promise<void>;
42
51
  }
52
+ /** Resume a thread for a Cohub Turn, waiting out an unload in progress. */
53
+ export declare function resumeThread(channel: CodexChannel, params: JsonRecord): Promise<JsonRecord>;
@@ -10,6 +10,12 @@ export const CODEX_SHARED_MIN_VERSION = [0, 156, 0];
10
10
  const REQUEST_TIMEOUT_MS = 60_000;
11
11
  const JOIN_ATTEMPTS = 20;
12
12
  const JOIN_RETRY_MS = 100;
13
+ const CLOSING_ATTEMPTS = 100;
14
+ /**
15
+ * A loaded thread holds Codex's per-thread writer lock, which Codex Desktop and other app-servers
16
+ * need to continue it. Long enough for an immediate follow-up Turn; honoured from Codex 0.164.
17
+ */
18
+ export const CODEX_UNLOAD_DELAY_SECS = 10;
13
19
  /** `codex app-server daemon start` returns once the server listens; anything slower has failed. */
14
20
  const DAEMON_START_TIMEOUT_MS = 30_000;
15
21
  const codexHome = () => process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
@@ -77,6 +83,7 @@ export class CodexShared {
77
83
  opening = null;
78
84
  ensured = false;
79
85
  connectListeners = new Set();
86
+ releaseListeners = new Set();
80
87
  driving = new Set();
81
88
  constructor(binary, cwd, enabled) {
82
89
  this.binary = binary;
@@ -106,6 +113,19 @@ export class CodexShared {
106
113
  this.connectListeners.add(listener);
107
114
  return () => { this.connectListeners.delete(listener); };
108
115
  }
116
+ onRelease(listener) {
117
+ this.releaseListeners.add(listener);
118
+ return () => { this.releaseListeners.delete(listener); };
119
+ }
120
+ /** Drop this connection's subscription so an idle thread can unload and free its writer lock. */
121
+ async release(threadId) {
122
+ for (const listener of this.releaseListeners)
123
+ listener(threadId);
124
+ const channel = this.channel;
125
+ if (!channel || channel.closed)
126
+ return;
127
+ await channel.request("thread/unsubscribe", { threadId }).catch(() => undefined);
128
+ }
109
129
  async join(threadId) {
110
130
  const channel = await this.get();
111
131
  return channel ? await joinThread(channel, threadId) : null;
@@ -232,6 +252,22 @@ class StdioChannel {
232
252
  }
233
253
  async close() { await this.rpc.close(); }
234
254
  }
255
+ /** Resume a thread for a Cohub Turn, waiting out an unload in progress. */
256
+ export async function resumeThread(channel, params) {
257
+ for (let attempt = 1;; attempt += 1) {
258
+ try {
259
+ return await channel.request("thread/resume", params);
260
+ }
261
+ catch (error) {
262
+ const message = error instanceof Error ? error.message : String(error);
263
+ if (message.includes("already has an active writer"))
264
+ throw new Error("This Codex conversation is open in another app, such as Codex Desktop. Close it there, then retry.");
265
+ if (!message.includes("is closing") || attempt >= CLOSING_ATTEMPTS)
266
+ throw error;
267
+ await new Promise((resolve) => setTimeout(resolve, JOIN_RETRY_MS));
268
+ }
269
+ }
270
+ }
235
271
  /**
236
272
  * Join a thread that another client runs. A thread's rollout is written when its first Turn starts,
237
273
  * so a join that arrives a moment early is retried briefly instead of failing the observation.
@@ -51,6 +51,11 @@ export class NativeRuntime {
51
51
  });
52
52
  this.codex = new CodexShared(options.executables.codex || "codex", options.root, Boolean(options.config?.codexShared) && options.harnesses.includes("codex"));
53
53
  this.codex.onConnect((channel) => this.observeCodex(channel));
54
+ // Its next Turn is found again through the server's status broadcast.
55
+ this.codex.onRelease((threadId) => {
56
+ this.watched.delete(threadId);
57
+ this.endStream(`codex:${threadId}`);
58
+ });
54
59
  this.ingest = new NativeIngest({
55
60
  spaceId: options.spaceId,
56
61
  cwd: options.root,
@@ -311,6 +316,7 @@ export class NativeRuntime {
311
316
  this.endStream(key);
312
317
  if (watched.path)
313
318
  this.ingest.touch(watched.path);
319
+ void this.codex.release(threadId);
314
320
  return;
315
321
  }
316
322
  this.stream(key, "codex", threadId).translator.push(event);
@@ -158,11 +158,22 @@ async function scanCodexRollouts(signal) {
158
158
  codexRolloutIndexCache = { at: Date.now(), home, index };
159
159
  return index;
160
160
  }
161
+ async function* compressedCodexRolloutBytes(path, signal) {
162
+ const decoder = createZstdDecompress();
163
+ // pipeline keeps error listeners attached, so stopping at a header or prefix cannot crash with an unhandled decoder error.
164
+ const pumping = pipeline(createReadStream(path), decoder, { signal }).catch(() => { });
165
+ try {
166
+ yield* decoder;
167
+ }
168
+ finally {
169
+ decoder.destroy();
170
+ await pumping;
171
+ }
172
+ }
161
173
  /** Raw JSONL bytes of a rollout, transparently decompressing `.zst` and honoring a lineage cutoff. */
162
174
  export async function* codexRolloutBytes(path, endBytes, signal) {
163
- // compose (not pipe) so a source error propagates and early exit destroys both streams.
164
175
  const stream = path.endsWith(".zst")
165
- ? createReadStream(path, { signal }).compose(createZstdDecompress())
176
+ ? compressedCodexRolloutBytes(path, signal)
166
177
  : createReadStream(path, endBytes != null ? { end: endBytes - 1, signal } : { signal });
167
178
  let total = 0, last = -1;
168
179
  for await (const chunk of stream) {
@@ -522,9 +533,10 @@ function parseCodexLines(lines, segment) {
522
533
  calls.get(id)?.content.push({ type: "tool_result", tool_use_id: id, content: typeof payload.output === "string" ? payload.output : JSON.stringify(payload.output ?? null) });
523
534
  }
524
535
  }
536
+ // Cohub tags a Turn it starts with its own cloud Turn id (`client_id`), so the file itself records
537
+ // which Turns already belong to a cloud Turn and must not be ingested twice. Older Codex writes it
538
+ // on `item_completed`'s UserMessage, newer Codex on `user_message`.
525
539
  if (entry.type === "event_msg" && payload.type === "item_completed") {
526
- // Cohub tags a Turn it starts with its own cloud Turn id, so the file itself records which
527
- // Turns already belong to a cloud Turn (and must not be ingested twice).
528
540
  const item = record(payload.item);
529
541
  if (item.type === "UserMessage") {
530
542
  const clientId = text(item.client_id);
@@ -540,6 +552,9 @@ function parseCodexLines(lines, segment) {
540
552
  }
541
553
  }
542
554
  if (entry.type === "event_msg" && payload.type === "user_message") {
555
+ const clientId = text(payload.client_id);
556
+ if (clientId)
557
+ current.cloudTurnId = clientId;
543
558
  if (!userFromResponse)
544
559
  current.userContent = [{ type: "text", text: text(payload.message) }];
545
560
  userFromResponse = true;
@@ -152,11 +152,23 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
152
152
  });
153
153
  bridge.stdin?.on("error", () => undefined);
154
154
  const closed = new Promise((resolve) => {
155
+ let drainTimer;
155
156
  bridge.once("error", (error) => diagnostics.log("error", "sandboxd.process_error", { error: serializeDiagnosticError(error) }));
156
- bridge.once("close", (code, exitSignal) => {
157
+ bridge.once("exit", (code, exitSignal) => {
157
158
  childStopped = true;
158
159
  if (!signal.aborted)
159
160
  diagnostics.log("warn", "sandboxd.process_exit", { code, signal: exitSignal });
161
+ // Descendants may still hold inherited pipes. After draining buffered
162
+ // output, close only our ends so supervision can continue.
163
+ drainTimer = setTimeout(() => {
164
+ diagnostics.log("warn", "sandboxd.stdio_drain_timeout");
165
+ for (const stream of bridge.stdio)
166
+ stream?.destroy();
167
+ }, 1000);
168
+ });
169
+ bridge.once("close", () => {
170
+ childStopped = true;
171
+ clearTimeout(drainTimer);
160
172
  resolve();
161
173
  });
162
174
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "10.8.1",
3
+ "version": "10.8.3",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -18,7 +18,7 @@
18
18
  "NOTICE"
19
19
  ],
20
20
  "dependencies": {
21
- "@neta-art/cohub": "12.1.0",
21
+ "@neta-art/cohub": "12.1.1",
22
22
  "@neta-art/generation": "^0.1.40",
23
23
  "commander": "^15.0.0",
24
24
  "sharp": "^0.35.4",