@neta-art/cohub-cli 10.8.1 → 10.8.2

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.
@@ -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) {
@@ -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);
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.2",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {