@neta-art/cohub-cli 10.3.0 → 10.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { mkdir } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
5
  import { BOARD_PRESET_NAMES, BOARD_SCHEMA_TARGETS, BOARD_SCRIPT_API, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
6
6
  import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
7
+ import { BOARD_EXPORT_IMAGE_MEMORY_MIB } from "../board-export-resources.js";
7
8
  import { formatBoardTime, parseBoardTime, parseBoardTimes } from "../board-time.js";
8
9
  import { createClient, createRealtimeClient } from "../client.js";
9
10
  import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
@@ -11,6 +12,16 @@ import { resolveSpace } from "../space.js";
11
12
  import { registerBoardExampleCommands } from "./boards/examples.js";
12
13
  import { registerBoardAuthoringCommands } from "./boards/authoring.js";
13
14
  export const parseJsonObject = parseBoardJsonObject;
15
+ export function boardSchemaField(schema, path) {
16
+ let field = schema;
17
+ for (const part of path.split(".")) {
18
+ const properties = field?.properties;
19
+ if (!part || !properties || !Object.hasOwn(properties, part))
20
+ throw new Error(`Unknown schema field ${path}.`);
21
+ field = properties[part];
22
+ }
23
+ return field;
24
+ }
14
25
  function withJson(command) {
15
26
  return command.option("--json", "Output as JSON");
16
27
  }
@@ -152,6 +163,7 @@ function registerExportCommand(boards) {
152
163
  .option("--format <format>", `Override the format (${BOARD_EXPORT_FORMATS.join(", ")}, ${BOARD_VIDEO_FORMATS.join(", ")})`)
153
164
  .option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
154
165
  .option("--fps <rate>", "Video frame rate; defaults to the --at step")
166
+ .option("--image-memory <mib>", "Decoded image memory budget in MiB", String(BOARD_EXPORT_IMAGE_MEMORY_MIB))
155
167
  .option("--no-images", "Draw placeholders instead of downloading images")
156
168
  .option("--force", "Replace existing output files"))
157
169
  .addHelpText("after", `
@@ -180,6 +192,11 @@ Examples:
180
192
  if (!jsonRequested(options) && (completed === total || completed % 30 === 0))
181
193
  process.stderr.write(`Rendered ${completed}/${total} frames\n`);
182
194
  };
195
+ const imageMemoryMiB = parseNumber(options.imageMemory ?? String(BOARD_EXPORT_IMAGE_MEMORY_MIB), "--image-memory", { min: 1, max: 65536, integer: true });
196
+ const onPlan = ({ width, height, frames }) => {
197
+ if (!jsonRequested(options))
198
+ process.stderr.write(`Rendering ${width}x${height}, ${frames} frame(s)\n`);
199
+ };
183
200
  const pathVideoFormat = videoFormatFromPath(out);
184
201
  const requestedFormat = options.format?.toLowerCase();
185
202
  if (requestedFormat && ![...BOARD_EXPORT_FORMATS, ...BOARD_VIDEO_FORMATS].includes(requestedFormat))
@@ -204,6 +221,8 @@ Examples:
204
221
  target,
205
222
  camera,
206
223
  onProgress: progress,
224
+ onPlan,
225
+ imageMemoryMiB,
207
226
  signal: controller.signal,
208
227
  force: options.force,
209
228
  region: parseExportRegion(options),
@@ -238,6 +257,8 @@ Examples:
238
257
  target,
239
258
  camera,
240
259
  onProgress: progress,
260
+ onPlan,
261
+ imageMemoryMiB,
241
262
  signal: controller.signal,
242
263
  onFrame: async (frame, index) => {
243
264
  const path = sequence ? framePath(out, index) : out;
@@ -448,17 +469,22 @@ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
448
469
  });
449
470
  boards.command("schema [target]")
450
471
  .description("Print JSON Schema for a part of a Board")
472
+ .option("--field <path>", "Print one field, e.g. props.decoration or props.geometry")
451
473
  .addHelpText("after", `
452
474
  Without a target: units, item types, colors and every animatable property.
453
475
  With a target: its fields and what they mean; script also lists the module API.
454
476
  Targets: ${BOARD_SCHEMA_TARGETS.join(", ")}`)
455
- .action((target) => {
477
+ .action((target, options) => {
456
478
  try {
479
+ if (options.field && !target)
480
+ throw new Error("--field needs a schema target, e.g. schema frame --field props.decoration.");
457
481
  if (!target)
458
482
  return outJson({ ...boardSchemaOverview(), presets: listBoardPresets() });
459
483
  const schema = boardJsonSchema(target);
460
484
  if (!schema)
461
485
  throw new Error(`Unknown target ${target}; expected ${BOARD_SCHEMA_TARGETS.join(", ")}.`);
486
+ if (options.field)
487
+ return outJson(boardSchemaField(schema.schema, options.field));
462
488
  outJson(target === "script" ? { ...schema, api: BOARD_SCRIPT_API } : schema);
463
489
  }
464
490
  catch (cause) {
@@ -1,9 +1,9 @@
1
- export declare const SANDBOXD_VERSION = "v2.67.0";
1
+ export declare const SANDBOXD_VERSION = "v2.68.0";
2
2
  export declare class SandboxdDownloadError extends Error {
3
3
  name: string;
4
4
  }
5
- export declare function validSandboxdArchiveEntries(entries: string[]): boolean;
6
- export declare function validateSandboxdArchive(archivePath: string): Promise<string[]>;
5
+ export declare function validSandboxdArchiveEntries(entries: string[], goos: string): boolean;
6
+ export declare function validateSandboxdArchive(archivePath: string, goos: string): Promise<string[]>;
7
7
  export type EnsureSandboxdOptions = {
8
8
  version?: string;
9
9
  onStatus?: (message: string) => void;
@@ -7,8 +7,9 @@ import { dirname, join } from "node:path";
7
7
  import { pipeline } from "node:stream/promises";
8
8
  import { Readable } from "node:stream";
9
9
  // Pin only after all platform artifacts are published to the public CDN.
10
- export const SANDBOXD_VERSION = "v2.67.0";
10
+ export const SANDBOXD_VERSION = "v2.68.0";
11
11
  const BINARY_NAME = "cohub-sandboxd";
12
+ const binaryName = (goos) => goos === "windows" ? `${BINARY_NAME}.exe` : BINARY_NAME;
12
13
  // Public CDN prefix hosting the release archives (the repo is private, so the
13
14
  // GitHub Release assets are not publicly downloadable). Overridable for staging
14
15
  // or self-hosting.
@@ -16,7 +17,7 @@ const cdnBaseUrl = () => (process.env.COHUB_SANDBOXD_CDN_BASE_URL?.trim() || "ht
16
17
  const DOWNLOAD_TIMEOUT_MS = 120_000;
17
18
  const LOCK_STALE_MS = 5 * 60 * 1000;
18
19
  // Map Node's platform/arch to the Go GOOS/GOARCH used in release asset names.
19
- const GOOS_BY_PLATFORM = { darwin: "darwin", linux: "linux" };
20
+ const GOOS_BY_PLATFORM = { darwin: "darwin", linux: "linux", win32: "windows" };
20
21
  const GOARCH_BY_ARCH = { x64: "amd64", arm64: "arm64" };
21
22
  export class SandboxdDownloadError extends Error {
22
23
  name = "SandboxdDownloadError";
@@ -30,7 +31,7 @@ const resolveTarget = () => {
30
31
  return { goos, goarch };
31
32
  };
32
33
  const cacheDir = (version) => join(homedir(), ".cache", "cohub", "sandboxd", version);
33
- const cachedBinaryPath = (version) => join(cacheDir(version), BINARY_NAME);
34
+ const cachedBinaryPath = (version, target) => join(cacheDir(version), binaryName(target.goos));
34
35
  const archiveName = (version, target) => `${BINARY_NAME}_${version}_${target.goos}_${target.goarch}.tar.gz`;
35
36
  const isExecutableFile = async (path) => {
36
37
  const info = await lstat(path).catch(() => null);
@@ -85,14 +86,23 @@ const fetchText = (url, accept) => withTimeout(`Download of ${url}`, async (sign
85
86
  return (await response.text()).trim();
86
87
  });
87
88
  // Every published release ships the binary alongside its LICENSE and NOTICE.
88
- const EXPECTED_ARCHIVE_ENTRIES = [BINARY_NAME, "LICENSE", "NOTICE"];
89
- export function validSandboxdArchiveEntries(entries) {
90
- return (entries.length === EXPECTED_ARCHIVE_ENTRIES.length &&
91
- EXPECTED_ARCHIVE_ENTRIES.every((entry) => entries.includes(entry)));
89
+ export function validSandboxdArchiveEntries(entries, goos) {
90
+ const expected = [binaryName(goos), "LICENSE", "NOTICE"];
91
+ return (entries.length === expected.length &&
92
+ expected.every((entry) => entries.includes(entry)));
92
93
  }
94
+ // Git for Windows puts GNU tar on PATH, which treats `C:\...` as a remote host.
95
+ // Use the system bsdtar (Windows 10+) instead.
96
+ const tarCommand = () => {
97
+ if (process.platform !== "win32")
98
+ return "tar";
99
+ if (!process.env.SystemRoot)
100
+ throw new SandboxdDownloadError("SystemRoot is not set; cannot locate tar.exe");
101
+ return join(process.env.SystemRoot, "System32", "tar.exe");
102
+ };
93
103
  // Reject unexpected paths before extracting the checksum-verified release.
94
104
  const listTarGz = (archivePath, verbose = false) => new Promise((res, rej) => {
95
- const child = spawn("tar", [verbose ? "-tvzf" : "-tzf", archivePath], { stdio: ["ignore", "pipe", "pipe"] });
105
+ const child = spawn(tarCommand(), [verbose ? "-tvzf" : "-tzf", archivePath], { stdio: ["ignore", "pipe", "pipe"] });
96
106
  let stdout = "";
97
107
  let stderr = "";
98
108
  child.stdout.on("data", (chunk) => {
@@ -106,9 +116,9 @@ const listTarGz = (archivePath, verbose = false) => new Promise((res, rej) => {
106
116
  ? res(stdout.split("\n").map((line) => line.trim()).filter(Boolean))
107
117
  : rej(new SandboxdDownloadError(`tar listing failed: ${stderr.trim() || `exit ${code}`}`)));
108
118
  });
109
- export async function validateSandboxdArchive(archivePath) {
119
+ export async function validateSandboxdArchive(archivePath, goos) {
110
120
  const entries = await listTarGz(archivePath);
111
- if (!validSandboxdArchiveEntries(entries)) {
121
+ if (!validSandboxdArchiveEntries(entries, goos)) {
112
122
  throw new SandboxdDownloadError(`Unexpected sandbox archive contents: ${entries.join(", ") || "(empty)"}`);
113
123
  }
114
124
  const details = await listTarGz(archivePath, true);
@@ -120,7 +130,7 @@ export async function validateSandboxdArchive(archivePath) {
120
130
  // Extract the verified `.tar.gz` using the system tar (universally present on
121
131
  // macOS and Linux), keeping the CLI free of native archive dependencies.
122
132
  const extractTarGz = (archivePath, cwd) => new Promise((res, rej) => {
123
- const child = spawn("tar", ["-xzf", archivePath, "-C", cwd], { stdio: ["ignore", "ignore", "pipe"] });
133
+ const child = spawn(tarCommand(), ["-xzf", archivePath, "-C", cwd], { stdio: ["ignore", "ignore", "pipe"] });
124
134
  let stderr = "";
125
135
  child.stderr.on("data", (chunk) => {
126
136
  stderr += chunk.toString();
@@ -175,19 +185,19 @@ const downloadAndVerify = async (version, target) => {
175
185
  if (actual !== expected) {
176
186
  throw new SandboxdDownloadError(`Checksum mismatch for ${name} (expected ${expected}, got ${actual})`);
177
187
  }
178
- const entries = await validateSandboxdArchive(archivePath);
188
+ const entries = await validateSandboxdArchive(archivePath, target.goos);
179
189
  await extractTarGz(archivePath, tempDir);
180
190
  for (const entry of entries) {
181
191
  if (!(await isSafeArchiveFile(join(tempDir, entry)))) {
182
192
  throw new SandboxdDownloadError(`Unsafe sandbox archive entry: ${entry}`);
183
193
  }
184
194
  }
185
- const extractedBinary = join(tempDir, BINARY_NAME);
195
+ const extractedBinary = join(tempDir, binaryName(target.goos));
186
196
  if (!(await isSafeArchiveFile(extractedBinary))) {
187
- throw new SandboxdDownloadError(`Archive ${name} did not contain ${BINARY_NAME}`);
197
+ throw new SandboxdDownloadError(`Archive ${name} did not contain ${binaryName(target.goos)}`);
188
198
  }
189
199
  // Atomically move into the version cache (fall back to copy across devices).
190
- const dest = cachedBinaryPath(version);
200
+ const dest = cachedBinaryPath(version, target);
191
201
  await mkdir(dirname(dest), { recursive: true });
192
202
  await rename(extractedBinary, dest).catch(async (err) => {
193
203
  if (err.code !== "EXDEV")
@@ -212,10 +222,10 @@ export const ensureSandboxdBinary = async (options = {}) => {
212
222
  return override;
213
223
  }
214
224
  const version = options.version?.trim() || SANDBOXD_VERSION;
215
- const cached = cachedBinaryPath(version);
225
+ const target = resolveTarget();
226
+ const cached = cachedBinaryPath(version, target);
216
227
  if (await isExecutableFile(cached))
217
228
  return cached;
218
- const target = resolveTarget();
219
229
  return withLock(version, async () => {
220
230
  // Re-check inside the lock: another process may have finished downloading.
221
231
  if (await isExecutableFile(cached))
@@ -5,6 +5,11 @@ export type HarnessOptions = {
5
5
  codex?: string;
6
6
  };
7
7
  export declare function harnessExecutableCandidates(binary: string, cwd: string, path: string, platform?: NodeJS.Platform, pathExt?: string | undefined): string[];
8
+ /**
9
+ * Windows npm/pnpm installs expose harnesses as .cmd shims, which cannot be spawned
10
+ * without a shell. Run an .exe directly, or the shim's Node entry with this Node.
11
+ */
12
+ export declare function harnessCommand(binary: string, args: string[], cwd: string): [string, string[]];
8
13
  /** Discover installed executables only; authentication/capability errors stay explicit. */
9
14
  export declare function installedHarnesses(cwd: string, options: HarnessOptions, path?: string): Promise<("pi" | "codex")[]>;
10
15
  /** Aborted streams leave tool calls with an empty id or name. */
@@ -1,5 +1,5 @@
1
1
  import { access, stat } from "node:fs/promises";
2
- import { constants } from "node:fs";
2
+ import { constants, readFileSync, statSync } from "node:fs";
3
3
  import { delimiter, join, resolve, win32 } from "node:path";
4
4
  import { JsonRpcProcess, record } from "./json-rpc.js";
5
5
  import { codexModelCatalog, piModelCatalog } from "./model-catalog.js";
@@ -7,14 +7,40 @@ import { downloadPublicImage } from "../safe-remote-image.js";
7
7
  export function harnessExecutableCandidates(binary, cwd, path, platform = process.platform, pathExt = process.env.PATHEXT) {
8
8
  const windows = platform === "win32";
9
9
  const paths = windows ? win32 : { delimiter, join, resolve };
10
+ // Windows runs only PATHEXT names; npm/pnpm also write an extensionless sh script beside the .cmd shim.
10
11
  const extensions = windows && !win32.extname(binary)
11
- ? ["", ...(pathExt || ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => /^\.[a-z0-9]+$/i.test(ext))]
12
+ ? (pathExt || ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => /^\.[a-z0-9]+$/i.test(ext))
12
13
  : [""];
13
14
  const roots = binary.includes("/") || binary.includes("\\")
14
15
  ? [paths.resolve(cwd, binary)]
15
16
  : path.split(paths.delimiter).map((directory) => directory.replace(/^"(.*)"$/, "$1")).filter(Boolean).map((directory) => paths.resolve(cwd, directory, binary));
16
17
  return roots.flatMap((root) => extensions.map((ext) => `${root}${ext}`));
17
18
  }
19
+ /**
20
+ * Windows npm/pnpm installs expose harnesses as .cmd shims, which cannot be spawned
21
+ * without a shell. Run an .exe directly, or the shim's Node entry with this Node.
22
+ */
23
+ export function harnessCommand(binary, args, cwd) {
24
+ if (process.platform !== "win32")
25
+ return [binary, args];
26
+ const path = harnessExecutableCandidates(binary, cwd, process.env.PATH ?? "").find((candidate) => statSync(candidate, { throwIfNoEntry: false })?.isFile());
27
+ if (!path)
28
+ throw new Error(`${binary} was not found on PATH`);
29
+ const extension = win32.extname(path).toLowerCase();
30
+ if (extension === ".exe" || extension === ".com")
31
+ return [path, args];
32
+ if ([".js", ".mjs", ".cjs"].includes(extension))
33
+ return [process.execPath, [path, ...args]];
34
+ if (extension === ".cmd") {
35
+ // npm (cmd-shim): SET "_prog=node" … "%dp0%\entry" %*; pnpm (@zkochan/cmd-shim): node "%~dp0\entry" %*
36
+ const shim = readFileSync(path, "utf8");
37
+ const runsNode = /SET "_prog=node"/i.test(shim) || /^\s*node\s+"%~dp0\\/im.test(shim);
38
+ const entry = runsNode ? /"%~?dp0%?\\([^"]+)"\s+%\*/i.exec(shim)?.[1] : undefined;
39
+ if (entry)
40
+ return [process.execPath, [win32.join(win32.dirname(path), entry), ...args]];
41
+ }
42
+ throw new Error(`Cannot run ${path} without a shell; pass --pi/--codex with an .exe or Node script`);
43
+ }
18
44
  /** Discover installed executables only; authentication/capability errors stay explicit. */
19
45
  export async function installedHarnesses(cwd, options, path = process.env.PATH ?? "") {
20
46
  const names = options.pi || options.codex ? ["pi", "codex"].filter((name) => options[name]) : ["pi", "codex"];
@@ -64,7 +90,7 @@ async function initializeCodex(rpc) {
64
90
  export async function discoverHarnesses(harnesses, options, cwd) {
65
91
  const models = [];
66
92
  await Promise.all(harnesses.map(async (harness) => {
67
- const rpc = new JsonRpcProcess(options[harness] || harness, harness === "pi" ? ["--mode", "rpc", "--no-session"] : ["app-server", "--listen", "stdio://"], cwd, harness);
93
+ const rpc = new JsonRpcProcess(...harnessCommand(options[harness] || harness, harness === "pi" ? ["--mode", "rpc", "--no-session"] : ["app-server", "--listen", "stdio://"], cwd), cwd, harness);
68
94
  try {
69
95
  if (harness === "pi") {
70
96
  const result = await rpc.request("get_available_models");
@@ -67,7 +67,7 @@ export class JsonRpcProcess {
67
67
  closing = null;
68
68
  constructor(binary, args, cwd, mode, context = {}) {
69
69
  this.mode = mode;
70
- this.child = spawn(binary, args, { cwd, env: { ...harnessEnvironment(), ...context }, stdio: "pipe", detached: process.platform !== "win32" });
70
+ this.child = spawn(binary, args, { cwd, env: { ...harnessEnvironment(), ...context }, stdio: "pipe", detached: process.platform !== "win32", windowsHide: true });
71
71
  this.closed = new Promise((resolve) => this.child.once("close", () => resolve()));
72
72
  const decoder = new JsonLineDecoder((value) => this.receive(value));
73
73
  this.child.stdout.on("data", (chunk) => { try {
@@ -1,7 +1,8 @@
1
- import { fork } from "node:child_process";
1
+ import { execFile, fork } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { stat } from "node:fs/promises";
4
- import { basename, resolve } from "node:path";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { promisify } from "node:util";
5
6
  import { createInterface } from "node:readline/promises";
6
7
  import { isLocalHarness } from "@neta-art/cohub";
7
8
  import { discoverHarnesses, installedHarnesses } from "./harness.js";
@@ -23,11 +24,11 @@ export function parseRuntimeHarnesses(values) {
23
24
  export function parseRuntimeDisplay(value, platform = process.platform, env = process.env) {
24
25
  if (value === false)
25
26
  return undefined;
26
- if (value === undefined && platform !== "darwin" && (platform !== "linux" || !env.DISPLAY))
27
+ if (value === undefined && platform !== "darwin" && platform !== "win32" && (platform !== "linux" || !env.DISPLAY))
27
28
  return undefined;
28
29
  const spec = value === undefined || value === true ? "auto" : value.trim();
29
- if (!/^(auto|macos|x11(:[\w.:-]+)?|xvfb(:\d{3,4}x\d{3,4})?)$/.test(spec)) {
30
- throw new Error("Display must be auto, macos, x11[:display] or xvfb[:WIDTHxHEIGHT]");
30
+ if (!/^(auto|macos|windows|x11(:[\w.:-]+)?|xvfb(:\d{3,4}x\d{3,4})?)$/.test(spec)) {
31
+ throw new Error("Display must be auto, macos, windows, x11[:display] or xvfb[:WIDTHxHEIGHT]");
31
32
  }
32
33
  if (platform === "linux" && spec === "auto" && !env.DISPLAY) {
33
34
  throw new Error(env.WAYLAND_DISPLAY
@@ -52,7 +53,9 @@ export async function startBackgroundRuntime(config) {
52
53
  return new Promise((resolve, reject) => {
53
54
  // Keep the same Node executable and loader (also works from source in tests).
54
55
  const workerUrl = new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js", import.meta.url);
55
- const child = fork(workerUrl, [], { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"] });
56
+ // fork forwards spawn options; windowsHide keeps a detached Windows worker from opening a console.
57
+ const forkOptions = { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"], windowsHide: true };
58
+ const child = fork(workerUrl, [], forkOptions);
56
59
  const diagnosticConsole = createDiagnosticConsole(config.verbose);
57
60
  let last = null;
58
61
  let settled = false;
@@ -89,7 +92,19 @@ export async function startBackgroundRuntime(config) {
89
92
  child.send(config);
90
93
  });
91
94
  }
95
+ /** Collaborators execute as this OS user; an elevated Windows token would grant them administrator rights. */
96
+ async function assertNotElevatedOnWindows() {
97
+ if (process.platform !== "win32")
98
+ return;
99
+ if (!process.env.SystemRoot)
100
+ throw new Error("SystemRoot is not set; cannot check terminal elevation");
101
+ const { stdout } = await promisify(execFile)(join(process.env.SystemRoot, "System32", "whoami.exe"), ["/groups"], { encoding: "utf8", windowsHide: true });
102
+ // High (S-1-16-12288) and System (S-1-16-16384) integrity labels mark an elevated token.
103
+ if (/\bS-1-16-(?:12288|16384)\b/.test(stdout))
104
+ throw new Error("Run cohub runtime up from a non-elevated terminal");
105
+ }
92
106
  export async function runtimeUp(program, dir, options) {
107
+ await assertNotElevatedOnWindows();
93
108
  const requestedRoot = resolve(dir ?? process.cwd());
94
109
  if (!(await stat(requestedRoot)).isDirectory())
95
110
  throw new Error("Workspace is not a directory");
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { serializeDiagnosticError } from "../diagnostics.js";
3
- import { CODEX_SHARED_MIN_VERSION, supportsSharedServer } from "./codex-link.js";
3
+ import { CODEX_SHARED_MIN_VERSION, sharedServerAvailable, supportsSharedServer } from "./codex-link.js";
4
4
  import { PI_MIN_VERSION } from "./pi-link.js";
5
5
  import { atLeast, formatVersion, harnessVersion } from "./version.js";
6
6
  import { readNativeConfig, runtimeStateRoot, writeNativeConfig } from "./config.js";
@@ -43,7 +43,10 @@ export async function ensureNativeSync(input, log = (line) => process.stderr.wri
43
43
  let codexShared = previous?.codexShared ?? false;
44
44
  if (input.harnesses.includes("codex") && !codexShared) {
45
45
  const version = await harnessVersion(input.executables?.codex || "codex");
46
- if (!supportsSharedServer(version)) {
46
+ if (!sharedServerAvailable) {
47
+ log("Shared Codex sessions are unavailable on Windows; terminal Codex chats sync read-only\n");
48
+ }
49
+ else if (!supportsSharedServer(version)) {
47
50
  log(`Codex ${formatVersion(version)} predates the shared app-server (${CODEX_SHARED_MIN_VERSION.join(".")}+); terminal Codex chats sync read-only\n`);
48
51
  }
49
52
  else {
@@ -18,6 +18,8 @@ export interface CodexChannel {
18
18
  readonly shared: boolean;
19
19
  readonly closed: boolean;
20
20
  }
21
+ /** Codex's shared server listens on an AF_UNIX socket, which Node cannot reach on Windows. */
22
+ export declare const sharedServerAvailable: boolean;
21
23
  export declare const supportsSharedServer: (version: Version | null) => boolean;
22
24
  /** A private app-server over stdio, used when no shared one is available; it lives for one Turn. */
23
25
  export declare function openPrivateServer(rpc: JsonRpcProcess): Promise<CodexChannel>;
@@ -15,7 +15,9 @@ const DAEMON_START_TIMEOUT_MS = 30_000;
15
15
  const codexHome = () => process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
16
16
  /** The socket every Codex client on this machine discovers; a terminal `codex` attaches to it. */
17
17
  const codexControlSocket = () => join(codexHome(), "app-server-control", "app-server-control.sock");
18
- export const supportsSharedServer = (version) => atLeast(version, CODEX_SHARED_MIN_VERSION);
18
+ /** Codex's shared server listens on an AF_UNIX socket, which Node cannot reach on Windows. */
19
+ export const sharedServerAvailable = process.platform !== "win32";
20
+ export const supportsSharedServer = (version) => sharedServerAvailable && atLeast(version, CODEX_SHARED_MIN_VERSION);
19
21
  /** Start the shared app-server when none is running. */
20
22
  async function ensureSharedServer(binary, cwd) {
21
23
  if (existsSync(codexControlSocket()) && await probe())
@@ -1,4 +1,4 @@
1
- import { imageForPi } from "../harness.js";
1
+ import { harnessCommand, imageForPi } from "../harness.js";
2
2
  import { JsonRpcProcess, record, RpcProcessClosedError } from "../json-rpc.js";
3
3
  import { describeError } from "../../network-error.js";
4
4
  import { ProcessCleanupUncertainError } from "../process-group.js";
@@ -55,7 +55,7 @@ async function runPi(executor, turn, context) {
55
55
  let begun = false;
56
56
  try {
57
57
  if (!link) {
58
- host = new JsonRpcProcess(executor.executables.pi || "pi", ["--mode", "rpc", "--session", session.path, "-e", executor.piExtension], executor.cwd, "pi", turnEnvironment(input));
58
+ host = new JsonRpcProcess(...harnessCommand(executor.executables.pi || "pi", ["--mode", "rpc", "--session", session.path, "-e", executor.piExtension], executor.cwd), executor.cwd, "pi", turnEnvironment(input));
59
59
  watchHost(host, executor.diagnostics, context);
60
60
  const rpc = host;
61
61
  // Nobody is at this Pi's terminal to answer an extension's question.
@@ -209,7 +209,7 @@ async function runCodex(executor, turn, context) {
209
209
  let begun = false;
210
210
  try {
211
211
  if (!channel) {
212
- host = new JsonRpcProcess(executor.executables.codex || "codex", ["app-server", "--listen", "stdio://"], executor.cwd, "codex", turnEnvironment(input));
212
+ host = new JsonRpcProcess(...harnessCommand(executor.executables.codex || "codex", ["app-server", "--listen", "stdio://"], executor.cwd), executor.cwd, "codex", turnEnvironment(input));
213
213
  watchHost(host, executor.diagnostics, context);
214
214
  channel = await openPrivateServer(host);
215
215
  }
@@ -4,6 +4,7 @@ import { NATIVE_INGEST_MAX_TURNS, SETTLED_TURN_STATUSES } from "@neta-art/cohub"
4
4
  import { acceptsDirectory, acceptsTranscript, readSessionHeader, readTranscript, transcriptRoots } from "./adapters.js";
5
5
  import { nativeTurnId } from "./identity.js";
6
6
  import { ensurePlainCodexRollout } from "./transcript.js";
7
+ import { containsNativePath } from "./paths.js";
7
8
  const missing = (error) => error?.code === "ENOENT";
8
9
  /** Transcripts read in parallel per lane. The live lane never waits behind a backfill. */
9
10
  const LIVE_CONCURRENCY = 2;
@@ -39,9 +40,8 @@ const BOUNDARY_OVERLAP = Math.max(...Object.values(BOUNDARIES).flat().map((token
39
40
  const IMPORT_FAILURES_KEPT = 100;
40
41
  /** Headers are read this many at a time; each costs one small read. */
41
42
  const HEADER_CONCURRENCY = 16;
42
- const within = (root, cwd) => cwd === root || cwd.startsWith(`${root}/`);
43
43
  /** Whether a session in `cwd` belongs to the directory bound at `root`. */
44
- export const owns = (root, cwd, nested = []) => Boolean(cwd) && within(root, cwd) && !nested.some((inner) => within(inner, cwd));
44
+ export const owns = (root, cwd, nested = []) => Boolean(cwd) && containsNativePath(root, cwd) && !nested.some((inner) => containsNativePath(inner, cwd));
45
45
  /** Every transcript of the bound directory and its subdirectories, newest first. */
46
46
  export async function discoverTranscripts(cwd, harnesses, options = {}) {
47
47
  const found = [];
@@ -144,7 +144,7 @@ export class NativeIngest {
144
144
  return owns(this.options.cwd, cwd, this.nested);
145
145
  }
146
146
  exclude(nested) {
147
- this.nested = nested.filter((root) => root !== this.options.cwd && within(this.options.cwd, root));
147
+ this.nested = nested.filter((root) => !containsNativePath(root, this.options.cwd) && containsNativePath(this.options.cwd, root));
148
148
  }
149
149
  configure(harnesses, enabledAt) {
150
150
  const added = harnesses.filter((harness) => !this.harnesses.includes(harness));
@@ -560,7 +560,7 @@ export class NativeIngest {
560
560
  }
561
561
  harnessFor(path) {
562
562
  for (const harness of this.harnesses) {
563
- if (transcriptRoots(harness).some((root) => path.startsWith(`${root}/`)) && acceptsTranscript(path, harness, this.options.cwd))
563
+ if (transcriptRoots(harness).some((root) => containsNativePath(root, path)) && acceptsTranscript(path, harness, this.options.cwd))
564
564
  return harness;
565
565
  }
566
566
  return null;
@@ -0,0 +1,2 @@
1
+ /** Whether a native path is the root itself or one of its descendants. */
2
+ export declare function containsNativePath(root: string, path: string, platform?: NodeJS.Platform): boolean;
@@ -0,0 +1,7 @@
1
+ import { posix, win32 } from "node:path";
2
+ /** Whether a native path is the root itself or one of its descendants. */
3
+ export function containsNativePath(root, path, platform = process.platform) {
4
+ const paths = platform === "win32" ? win32 : posix;
5
+ const relative = paths.relative(root, path);
6
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative);
7
+ }
@@ -1,6 +1,6 @@
1
1
  // Cohub control bridge for Pi. Self-contained: Node built-ins only, so it installs as one file.
2
2
  import { createHash } from "node:crypto";
3
- import { existsSync, realpathSync } from "node:fs";
3
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
4
4
  import { connect } from "node:net";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
@@ -103,7 +103,17 @@ export default function cohub(pi) {
103
103
  const path = locateSocket(ctx.cwd);
104
104
  if (!path)
105
105
  return;
106
- const current = connect(path);
106
+ // On Windows the socket file names the Runtime's private named pipe.
107
+ let endpoint = path;
108
+ if (process.platform === "win32") {
109
+ try {
110
+ endpoint = readFileSync(path, "utf8").trim();
111
+ }
112
+ catch {
113
+ return;
114
+ }
115
+ }
116
+ const current = connect(endpoint);
107
117
  socket = current;
108
118
  buffer = "";
109
119
  current.on("connect", () => send({
@@ -1,5 +1,6 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { createServer } from "node:net";
2
- import { chmod, mkdir, rm } from "node:fs/promises";
3
+ import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { dirname } from "node:path";
4
5
  import { COHUB_PI_PROTOCOL } from "./pi-extension.js";
5
6
  const MAX_LINE_BYTES = 8 * 1024 * 1024;
@@ -68,17 +69,33 @@ export class PiLink {
68
69
  }
69
70
  async listen(signal) {
70
71
  const { socketPath } = this.options;
72
+ const windows = process.platform === "win32";
73
+ // Windows named pipes share one global namespace: listen on a random name and
74
+ // publish it at socketPath, so another account cannot pre-claim the endpoint.
75
+ const endpoint = windows ? `\\\\.\\pipe\\cohub-pi-${randomUUID()}` : socketPath;
71
76
  // The socket accepts commands that drive the user's agent: only this user may reach it.
72
77
  await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
73
- if (process.platform !== "win32")
78
+ if (!windows)
74
79
  await chmod(dirname(socketPath), 0o700);
75
80
  await rm(socketPath, { force: true });
76
81
  const server = createServer((socket) => this.accept(socket));
77
82
  await new Promise((resolve, reject) => {
78
83
  server.once("error", reject);
79
- server.listen(socketPath, () => { server.removeListener("error", reject); resolve(); });
84
+ server.listen(endpoint, () => { server.removeListener("error", reject); resolve(); });
80
85
  });
81
- if (process.platform !== "win32")
86
+ if (windows) {
87
+ const temporary = `${socketPath}.${randomUUID()}.tmp`;
88
+ try {
89
+ await writeFile(temporary, endpoint, { mode: 0o600 });
90
+ await rename(temporary, socketPath);
91
+ }
92
+ catch (error) {
93
+ server.close();
94
+ await rm(temporary, { force: true });
95
+ throw error;
96
+ }
97
+ }
98
+ else
82
99
  await chmod(socketPath, 0o600);
83
100
  this.server = server;
84
101
  signal.addEventListener("abort", () => {
@@ -1,8 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { harnessCommand } from "../harness.js";
3
4
  /** A harness binary's own version, or `null` when it cannot say. */
4
5
  export async function harnessVersion(binary) {
5
- const { stdout } = await promisify(execFile)(binary, ["--version"], { encoding: "utf8", timeout: 15_000 }).catch(() => ({ stdout: "" }));
6
+ const { stdout } = await Promise.resolve()
7
+ .then(() => promisify(execFile)(...harnessCommand(binary, ["--version"], process.cwd()), { encoding: "utf8", timeout: 15_000, windowsHide: true }))
8
+ .catch(() => ({ stdout: "" }));
6
9
  const match = /(\d+)\.(\d+)\.(\d+)/.exec(stdout);
7
10
  return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
8
11
  }