@neta-art/cohub-cli 10.8.3 → 10.11.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.
@@ -0,0 +1,4 @@
1
+ import { type BoardDocument, type BoardMediaFileResolver } from "@neta-art/cohub/board";
2
+ export declare function boardMediaFileResolver(spaceId: string): BoardMediaFileResolver;
3
+ export declare function settleBoardDocument(spaceId: string, document: BoardDocument): Promise<BoardDocument>;
4
+ export declare function settleBoardPatch<T extends Record<string, unknown>>(spaceId: string, patch: T, current: (ids: string[]) => Promise<Record<string, unknown> | null>): Promise<T>;
@@ -0,0 +1,63 @@
1
+ import { execFile } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ import { promisify } from "node:util";
4
+ import { diffMergePatch, mergePatch, parseBoardItem, settleBoardMediaSizes, } from "@neta-art/cohub/board";
5
+ import { createClient } from "./client.js";
6
+ import { WORKSPACE_ROOT } from "./space-files/operands.js";
7
+ const run = promisify(execFile);
8
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
9
+ const oriented = (width, height, turned) => width > 0 && height > 0 ? (turned ? { width: height, height: width } : { width, height }) : null;
10
+ async function localMediaSize(file, type, signal) {
11
+ try {
12
+ if (type === "image") {
13
+ const { default: sharp } = await import("sharp");
14
+ const { width = 0, height = 0, orientation = 1 } = await sharp(file).metadata();
15
+ return oriented(width, height, orientation >= 5);
16
+ }
17
+ const { stdout } = await run("ffprobe", [
18
+ "-v", "error", "-select_streams", "v:0",
19
+ "-show_entries", "stream=width,height:stream_side_data=rotation", "-of", "json", file,
20
+ ], { signal, timeout: 5_000 });
21
+ const stream = JSON.parse(stdout).streams?.[0];
22
+ const rotation = stream?.side_data_list?.find((data) => data.rotation !== undefined)?.rotation ?? 0;
23
+ return oriented(stream?.width ?? 0, stream?.height ?? 0, Math.abs(rotation) % 180 === 90);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function boardMediaFileResolver(spaceId) {
30
+ const local = process.env.COHUB_SPACE_ID === spaceId;
31
+ return async (path, type, signal) => {
32
+ const file = resolve(WORKSPACE_ROOT, path);
33
+ const size = local && file.startsWith(`${WORKSPACE_ROOT}/`) ? await localMediaSize(file, type, signal) : null;
34
+ return size ?? createClient().space(spaceId).files.resolveUrl(path, { purpose: "playback", signal }).catch(() => null);
35
+ };
36
+ }
37
+ export async function settleBoardDocument(spaceId, document) {
38
+ const settled = await settleBoardMediaSizes(document.items, { resolveFile: boardMediaFileResolver(spaceId) });
39
+ return Object.keys(settled).length ? { ...document, items: { ...document.items, ...settled } } : document;
40
+ }
41
+ export async function settleBoardPatch(spaceId, patch, current) {
42
+ const items = isRecord(patch.items) ? patch.items : {};
43
+ const created = {};
44
+ for (const [id, value] of Object.entries(items)) {
45
+ if (!isRecord(value) || (value.type !== "image" && value.type !== "video"))
46
+ continue;
47
+ const parsed = parseBoardItem(value, `items.${id}`);
48
+ if (parsed.ok)
49
+ created[id] = parsed.item;
50
+ }
51
+ const existing = Object.keys(created).length ? await current(Object.keys(created)) : null;
52
+ if (!existing)
53
+ return patch;
54
+ for (const [id, item] of Object.entries(existing)) {
55
+ if (isRecord(item) && item.type === created[id]?.type)
56
+ delete created[id];
57
+ }
58
+ const settled = await settleBoardMediaSizes(created, { resolveFile: boardMediaFileResolver(spaceId) });
59
+ if (!Object.keys(settled).length)
60
+ return patch;
61
+ const adopted = Object.entries(settled).map(([id, item]) => [id, mergePatch(items[id], diffMergePatch(created[id], item))]);
62
+ return { ...patch, items: { ...items, ...Object.fromEntries(adopted) } };
63
+ }
@@ -1,5 +1,6 @@
1
1
  import { readBoardJsonObject } from "../../board-command-support.js";
2
2
  import { loadBoardDocument, resolveBundledFonts } from "../../board-export.js";
3
+ import { settleBoardDocument } from "../../board-media.js";
3
4
  import { boardPathTrack, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createNodeBoardRenderer, inspectBoardItems, } from "../../board-kernel.js";
4
5
  import { parseBoardTimes } from "../../board-time.js";
5
6
  import { handleHttp, json as outJson } from "../../output.js";
@@ -19,7 +20,8 @@ Checks native layout only, not camera cropping or content drawn inside scripts.
19
20
  Example: cohub boards inspect lesson.board --animation lecture --at 0:20s:1s --json`)
20
21
  .action(async (target, options) => {
21
22
  try {
22
- const { document } = await loadBoardDocument(await resolveSpace(boards), target);
23
+ const spaceId = await resolveSpace(boards);
24
+ const document = await settleBoardDocument(spaceId, (await loadBoardDocument(spaceId, target)).document);
23
25
  const times = parseBoardTimes(options.at, "--at", 100);
24
26
  const sampler = options.animation ? createBoardAnimationSampler(document, options.animation) : null;
25
27
  const renderer = await createNodeBoardRenderer({ fonts: resolveBundledFonts() });
@@ -2,9 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
- import { BOARD_PRESET_NAMES, BOARD_SCHEMA_TARGETS, BOARD_SCRIPT_API, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
6
- import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
5
+ import { BOARD_PRESET_NAMES, BOARD_SCHEMA_TARGETS, BOARD_SCRIPT_API, BOARD_THEME_IDS, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
6
+ import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, writeBoardOutput, } from "../board-command-support.js";
7
7
  import { BOARD_EXPORT_IMAGE_MEMORY_MIB } from "../board-export-resources.js";
8
+ import { settleBoardPatch } from "../board-media.js";
8
9
  import { formatBoardTime, parseBoardTime, parseBoardTimes } from "../board-time.js";
9
10
  import { createClient, createRealtimeClient } from "../client.js";
10
11
  import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
@@ -52,9 +53,9 @@ function list(value) {
52
53
  const entries = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
53
54
  return entries?.length ? entries : undefined;
54
55
  }
55
- async function boardClient(boards, target) {
56
- const spaceId = await resolveSpace(boards);
57
- return createClient().space(spaceId).board(await resolveBoardId(spaceId, target));
56
+ async function boardClient(boards, target, spaceId) {
57
+ spaceId ??= await resolveSpace(boards);
58
+ return createClient().space(spaceId).boards.open(target);
58
59
  }
59
60
  /** A patch from an inline argument, a file, or stdin (`-`). */
60
61
  async function readPatch(inline, input, maxBytes) {
@@ -158,7 +159,7 @@ function registerExportCommand(boards) {
158
159
  .option("--rect <rect>", "World rect as x,y,width,height")
159
160
  .option("--scale <factor>", "Output pixels per board unit", "2")
160
161
  .option("--padding <units>", "Padding around the content in board units")
161
- .option("--theme <mode>", "dark or light", "dark")
162
+ .option("--theme <name>", `Cohub theme: ${BOARD_THEME_IDS.join(", ")}`, "dark")
162
163
  .option("--paper <mode>", "Output paper: paper or transparent", "paper")
163
164
  .option("--format <format>", `Override the format (${BOARD_EXPORT_FORMATS.join(", ")}, ${BOARD_VIDEO_FORMATS.join(", ")})`)
164
165
  .option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
@@ -230,7 +231,7 @@ Examples:
230
231
  ...(options.animation ? { animation: options.animation } : {}),
231
232
  scale: parseNumber(options.scale ?? "2", "--scale", { min: 0.01, max: 16 }),
232
233
  ...(options.padding === undefined ? {} : { padding: parseNumber(options.padding, "--padding", { min: 0 }) }),
233
- colorScheme: parseChoice(options.theme, "--theme", ["dark", "light"]),
234
+ theme: parseChoice(options.theme, "--theme", BOARD_THEME_IDS),
234
235
  background: parseChoice(options.paper, "--paper", ["paper", "transparent"]),
235
236
  format: videoFormat,
236
237
  fps,
@@ -271,7 +272,7 @@ Examples:
271
272
  ...(options.animation ? { animation: options.animation } : {}),
272
273
  scale: parseNumber(options.scale ?? "2", "--scale", { min: 0.01, max: 16 }),
273
274
  ...(options.padding === undefined ? {} : { padding: parseNumber(options.padding, "--padding", { min: 0 }) }),
274
- colorScheme: parseChoice(options.theme, "--theme", ["dark", "light"]),
275
+ theme: parseChoice(options.theme, "--theme", BOARD_THEME_IDS),
275
276
  background: parseChoice(options.paper, "--paper", ["paper", "transparent"]),
276
277
  format: parseExportFormat(options, out),
277
278
  quality: parseNumber(options.quality ?? "0.92", "--quality", { min: 0, max: 1 }),
@@ -327,14 +328,16 @@ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
327
328
  cohub boards create lesson.board -i lesson.json`)
328
329
  .action(async (path, options) => {
329
330
  try {
330
- const document = options.input ? await readBoardJsonObject(options.input, BOARD_CREATE_INPUT_MAX_BYTES) : undefined;
331
+ const spaceId = await resolveSpace(boards);
332
+ const read = options.input ? await readBoardJsonObject(options.input, BOARD_CREATE_INPUT_MAX_BYTES) : undefined;
333
+ const document = read && await settleBoardPatch(spaceId, read, async () => ({}));
331
334
  const input = {
332
335
  path,
333
336
  mutationId: options.mutationId ?? randomUUID(),
334
337
  ...(options.title ? { title: options.title } : {}),
335
338
  ...(document ? { document: document } : {}),
336
339
  };
337
- const result = await createClient().space(await resolveSpace(boards)).boards.create(input);
340
+ const result = await createClient().space(spaceId).boards.create(input);
338
341
  if (jsonRequested(options))
339
342
  return outJson({ path, id: result.id, title: result.title, version: result.version });
340
343
  ok(`Created ${path}`);
@@ -411,8 +414,11 @@ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
411
414
  cohub boards preset rise --targets a,b --animation intro | cohub boards apply plan.board -i -`)
412
415
  .action(async (target, inline, options) => {
413
416
  try {
414
- const patch = await readPatch(inline, options.input, BOARD_TRANSACTION_INPUT_MAX_BYTES);
415
- const result = await (await boardClient(boards, target)).apply(patch, {
417
+ const spaceId = await resolveSpace(boards);
418
+ const board = await boardClient(boards, target, spaceId);
419
+ const read = await readPatch(inline, options.input, BOARD_TRANSACTION_INPUT_MAX_BYTES);
420
+ const patch = await settleBoardPatch(spaceId, read, async (items) => options.replace ? {} : board.get({ only: ["items"], items }).then((result) => result.items ?? {}, () => null));
421
+ const result = await board.apply(patch, {
416
422
  ...(options.replace ? { replace: true } : {}),
417
423
  ...(options.cascade ? { cascade: true } : {}),
418
424
  ...(options.dryRun ? { dryRun: true } : {}),
@@ -532,9 +538,9 @@ Presets: ${BOARD_PRESET_NAMES.join(", ")}
532
538
  .action(async (target, options) => {
533
539
  try {
534
540
  const spaceId = await resolveSpace(boards);
535
- const boardId = await resolveBoardId(spaceId, target);
536
541
  const client = createRealtimeClient();
537
- const board = client.space(spaceId).board(boardId);
542
+ const board = await client.space(spaceId).boards.open(target);
543
+ const boardId = board.id;
538
544
  if (!jsonRequested(options))
539
545
  process.stderr.write(`Watching Board ${boardId}…\n`);
540
546
  const offConnection = client.onConnection((state) => {
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerFeedback(program: Command): void;
@@ -0,0 +1,129 @@
1
+ import { openAsBlob } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { arch, platform, release } from "node:os";
4
+ import { basename, resolve } from "node:path";
5
+ import { mimeTypeForPath, readRequestSourceFromEnv, resolveApiBaseUrl, resolveCohubEnvironment } from "@neta-art/cohub";
6
+ import { authSource } from "../auth.js";
7
+ import { createClient } from "../client.js";
8
+ import { collectOption, readPromptContent } from "../command-input.js";
9
+ import { describeError } from "../network-error.js";
10
+ import { error, json as outJson, jsonRequested, spinner } from "../output.js";
11
+ import { readRuntimeDiagnosticEvents } from "../runtime/diagnostics.js";
12
+ import { resolveRuntimeTarget } from "../runtime/launch.js";
13
+ import { runtimeStateRoot } from "../runtime/native/config.js";
14
+ import { explicitSpace } from "../space.js";
15
+ // Shared with the web feedback dialog; cfeed enforces attachment limits server-side.
16
+ const CFEED_ENDPOINT = "https://cfeed.neta.art/api/v1/feedback";
17
+ const CFEED_APP = "cohub";
18
+ const RUNTIME_LOG_MAX_BYTES = 5 * 1024 * 1024;
19
+ const RUNTIME_LOG_EVENTS = 2_000;
20
+ const USER_LOOKUP_TIMEOUT_MS = 3_000;
21
+ const SUBMIT_TIMEOUT_MS = 120_000;
22
+ async function readAttachment(path) {
23
+ const absolute = resolve(path);
24
+ const info = await stat(absolute).catch(() => null);
25
+ if (!info?.isFile())
26
+ return error(`Cannot attach ${path}`, "Pass a path to an existing file");
27
+ const type = mimeTypeForPath(absolute) ?? "application/octet-stream";
28
+ return new File([await openAsBlob(absolute, { type })], basename(absolute), { type });
29
+ }
30
+ async function runtimeLogFile(program, now) {
31
+ const spaceId = await resolveRuntimeTarget(program).catch((cause) => error("Cannot attach Runtime logs", describeError(cause)));
32
+ let events = await readRuntimeDiagnosticEvents(runtimeStateRoot(spaceId), { limit: RUNTIME_LOG_EVENTS });
33
+ let dropped = 0;
34
+ let text = JSON.stringify({ spaceId, dropped, events });
35
+ // Keep the newest events when the log exceeds the budget.
36
+ while (Buffer.byteLength(text) > RUNTIME_LOG_MAX_BYTES && events.length > 0) {
37
+ const count = Math.ceil(events.length / 2);
38
+ events = events.slice(count);
39
+ dropped += count;
40
+ text = JSON.stringify({ spaceId, dropped, events });
41
+ }
42
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..+$/, "");
43
+ return new File([text], `cohub-runtime-${stamp}.json`, { type: "application/json" });
44
+ }
45
+ async function currentUserUuid() {
46
+ if (!authSource())
47
+ return null;
48
+ let timer;
49
+ const timeout = new Promise((done) => { timer = setTimeout(() => done(null), USER_LOOKUP_TIMEOUT_MS); });
50
+ const lookup = createClient().user.getMe().then((user) => user.uuid ?? null, () => null);
51
+ try {
52
+ return await Promise.race([lookup, timeout]);
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+ async function submitFeedback(content, extra, files) {
59
+ const body = new FormData();
60
+ body.set("app", CFEED_APP);
61
+ body.set("content", content);
62
+ body.set("extra", JSON.stringify(extra));
63
+ for (const file of files)
64
+ body.append("files", file, file.name);
65
+ const response = await fetch(CFEED_ENDPOINT, { method: "POST", body, signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS) });
66
+ const payload = (await response.json().catch(() => null));
67
+ if (!response.ok)
68
+ throw new Error(payload?.error?.message ?? `${response.status} ${response.statusText}`);
69
+ if (!payload)
70
+ throw new Error("Feedback service returned an empty response");
71
+ return payload;
72
+ }
73
+ export function registerFeedback(program) {
74
+ program
75
+ .command("feedback [message...]")
76
+ .description("Send feedback or a bug report to the Cohub team")
77
+ .option("-f, --file <path>", "Attach a file; repeatable", collectOption, [])
78
+ .option("--runtime-logs", "Attach recent local Runtime diagnostics for the target Space")
79
+ .option("--json", "Output as JSON")
80
+ .addHelpText("after", `
81
+ The message comes from arguments or stdin. CLI version, OS, environment, account,
82
+ and COHUB_* Space/Chat context are attached automatically; your working directory is not.
83
+
84
+ Examples:
85
+ $ cohub feedback "runtime up hangs after sign-in"
86
+ $ cohub feedback "Board export is blurry" -f board.png -f export.log
87
+ $ cohub feedback --runtime-logs "Codex chats stopped syncing"
88
+ $ cat report.md | cohub feedback`)
89
+ .action(async (words, opts) => {
90
+ const asJson = jsonRequested(opts);
91
+ const content = await readPromptContent(words);
92
+ const now = new Date();
93
+ const [files, userUuid] = await Promise.all([
94
+ Promise.all([
95
+ ...opts.file.map(readAttachment),
96
+ ...(opts.runtimeLogs ? [runtimeLogFile(program, now)] : []),
97
+ ]),
98
+ currentUserUuid(),
99
+ ]);
100
+ const extra = {
101
+ user_uuid: userUuid,
102
+ cli_version: program.version() ?? null,
103
+ node: process.versions.node,
104
+ os: `${platform()} ${release()}`,
105
+ arch: arch(),
106
+ env: resolveCohubEnvironment(),
107
+ api_base_url: resolveApiBaseUrl(),
108
+ space: explicitSpace(program),
109
+ source: readRequestSourceFromEnv(process.env, { via: "cli" }),
110
+ locale: Intl.DateTimeFormat().resolvedOptions().locale,
111
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
112
+ tty: Boolean(process.stdout.isTTY),
113
+ has_runtime_logs: Boolean(opts.runtimeLogs),
114
+ };
115
+ const sp = asJson ? null : spinner();
116
+ sp?.start("Sending feedback");
117
+ try {
118
+ const result = await submitFeedback(content, extra, files);
119
+ if (asJson)
120
+ return outJson(result);
121
+ sp?.stop(`Feedback sent · ${result.id}`);
122
+ }
123
+ catch (cause) {
124
+ if (sp)
125
+ process.stderr.write("\n");
126
+ error("Couldn't send feedback", describeError(cause));
127
+ }
128
+ });
129
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { registerAuth } from "./commands/auth.js";
6
6
  import { registerBoards } from "./commands/boards.js";
7
7
  import { registerChannels } from "./commands/channels.js";
8
8
  import { registerCronJobs } from "./commands/cron-jobs.js";
9
+ import { registerFeedback } from "./commands/feedback.js";
9
10
  import { registerGenerations } from "./commands/generations.js";
10
11
  import { registerMe } from "./commands/me.js";
11
12
  import { registerModels } from "./commands/models.js";
@@ -71,6 +72,7 @@ Common commands:
71
72
  cohub models ls
72
73
  cohub models ls --model-type multimodal
73
74
  cohub generate "A calm lake at sunrise" --model <model> --output lake.png
75
+ cohub feedback "Describe the problem" -f screenshot.png
74
76
 
75
77
  Target space:
76
78
  -s <space>, then COHUB_SPACE_ID, then the current directory Runtime binding.
@@ -108,6 +110,7 @@ registerTasks(program);
108
110
  registerCronJobs(program);
109
111
  registerApps(program);
110
112
  registerRooms(program);
113
+ registerFeedback(program);
111
114
  registerDesktop(program);
112
115
  registerLegacyUi(program);
113
116
  const argv = process.argv.slice(2);
@@ -90,6 +90,7 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
90
90
  };
91
91
  let closeInstance;
92
92
  let bridgeTask;
93
+ let retryDisconnectedBridge;
93
94
  let nativeStarted = false;
94
95
  let tokenInFlight = null;
95
96
  const token = (forceRefresh = false) => {
@@ -139,6 +140,9 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
139
140
  let probeTimer;
140
141
  let managed = false;
141
142
  let childStopped = false;
143
+ let bridgeDisconnected = false;
144
+ let terminating = false;
145
+ let recoveryTimer;
142
146
  let controlPipe = null;
143
147
  try {
144
148
  const initialToken = await token();
@@ -156,6 +160,8 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
156
160
  bridge.once("error", (error) => diagnostics.log("error", "sandboxd.process_error", { error: serializeDiagnosticError(error) }));
157
161
  bridge.once("exit", (code, exitSignal) => {
158
162
  childStopped = true;
163
+ clearTimeout(recoveryTimer);
164
+ recoveryTimer = undefined;
159
165
  if (!signal.aborted)
160
166
  diagnostics.log("warn", "sandboxd.process_exit", { code, signal: exitSignal });
161
167
  // Descendants may still hold inherited pipes. After draining buffered
@@ -198,8 +204,10 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
198
204
  managed = true;
199
205
  if (event.type === "connected" || event.type === "disconnected") {
200
206
  const connected = event.type === "connected";
207
+ bridgeDisconnected = !connected;
201
208
  update({ workspaceConnected: connected });
202
209
  diagnostics.log(connected ? "info" : "warn", `sandboxd.${event.type}`);
210
+ retryDisconnectedBridge?.();
203
211
  }
204
212
  }
205
213
  catch {
@@ -238,6 +246,11 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
238
246
  };
239
247
  probeTimer = setTimeout(() => void probe(), 1000);
240
248
  const terminate = () => {
249
+ if (terminating || childStopped)
250
+ return;
251
+ terminating = true;
252
+ clearTimeout(recoveryTimer);
253
+ recoveryTimer = undefined;
241
254
  // Windows has no SIGTERM; closing the managed stdin lets sandboxd stop gracefully.
242
255
  if (process.platform === "win32")
243
256
  bridge.stdin?.end();
@@ -246,6 +259,26 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
246
259
  const timeout = setTimeout(() => bridge.kill("SIGKILL"), 3000);
247
260
  void closed.finally(() => clearTimeout(timeout));
248
261
  };
262
+ // A managed bridge may be sleeping for five minutes after a lease
263
+ // rejection. Retry only after it reports a lost connection and the
264
+ // Harness has regained its lease; a connected bridge stays untouched.
265
+ retryDisconnectedBridge = () => {
266
+ if (signal.aborted || !status.harnessConnected || !bridgeDisconnected || terminating || childStopped) {
267
+ clearTimeout(recoveryTimer);
268
+ recoveryTimer = undefined;
269
+ }
270
+ else if (!recoveryTimer) {
271
+ // Give the bridge its normal retry and channel reattachment
272
+ // window before replacing a process stuck in config backoff.
273
+ recoveryTimer = setTimeout(() => {
274
+ recoveryTimer = undefined;
275
+ if (!signal.aborted && status.harnessConnected && bridgeDisconnected && !terminating && !childStopped) {
276
+ diagnostics.log("info", "sandboxd.lease_recovery");
277
+ terminate();
278
+ }
279
+ }, 30_000);
280
+ }
281
+ };
249
282
  signal.addEventListener("abort", terminate, { once: true });
250
283
  if (signal.aborted)
251
284
  terminate();
@@ -257,10 +290,12 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
257
290
  diagnostics.log("warn", "sandboxd.restart_failed", { error: serializeDiagnosticError(error) });
258
291
  }
259
292
  finally {
293
+ retryDisconnectedBridge = undefined;
260
294
  childStopped = true;
261
295
  controlPipe?.close();
262
296
  clearTimeout(refreshTimer);
263
297
  clearTimeout(probeTimer);
298
+ clearTimeout(recoveryTimer);
264
299
  update({ workspaceConnected: false });
265
300
  }
266
301
  if (Date.now() - startedAt > 60_000)
@@ -276,8 +311,8 @@ export async function runRuntime(config, onState, externalSignal, onDiagnostic)
276
311
  await serveRuntime({
277
312
  spaceId: config.spaceId, cwd: config.root, url: url.toString(), capabilities,
278
313
  runtimeId: status.runtimeId, diagnostics, token, signal, executor: native.executor,
279
- onReady: () => update({ harnessConnected: true }),
280
- onDisconnected: () => { native.connect(null); update({ harnessConnected: false }); },
314
+ onReady: () => { update({ harnessConnected: true }); retryDisconnectedBridge?.(); },
315
+ onDisconnected: () => { native.connect(null); update({ harnessConnected: false }); retryDisconnectedBridge?.(); },
281
316
  onNativeStop: (stop) => { native.stop(stop.sessionId, stop.turnId); },
282
317
  onNativeChannel: (send) => {
283
318
  native.connect(send);
@@ -11,6 +11,7 @@ export type FileOperand = SpaceOperand | LocalOperand;
11
11
  export declare class InvalidFileOperandError extends Error {
12
12
  name: string;
13
13
  }
14
+ export declare const WORKSPACE_ROOT = "/workspace";
14
15
  export declare function normalizeSpacePath(raw: string): string;
15
16
  export declare function parseSpaceFileOperand(raw: string): SpaceOperand;
16
17
  export declare function parseCopyOperand(raw: string, currentSpaceDeclared: boolean): FileOperand;
@@ -3,7 +3,7 @@ import { parseSpaceRef } from "@neta-art/cohub";
3
3
  export class InvalidFileOperandError extends Error {
4
4
  name = "InvalidFileOperandError";
5
5
  }
6
- const WORKSPACE_ROOT = "/workspace";
6
+ export const WORKSPACE_ROOT = "/workspace";
7
7
  const isWorkspacePath = (path) => path === WORKSPACE_ROOT || path.startsWith(`${WORKSPACE_ROOT}/`);
8
8
  export function normalizeSpacePath(raw) {
9
9
  let path = raw.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "10.8.3",
3
+ "version": "10.11.0",
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.1",
21
+ "@neta-art/cohub": "12.3.0",
22
22
  "@neta-art/generation": "^0.1.40",
23
23
  "commander": "^15.0.0",
24
24
  "sharp": "^0.35.4",
@@ -28,8 +28,9 @@
28
28
  "access": "public"
29
29
  },
30
30
  "devDependencies": {
31
- "@fontsource/geist": "^5.3.0",
32
- "@fontsource/geist-mono": "^5.3.0",
31
+ "@fontsource-variable/figtree": "^5.3.0",
32
+ "@fontsource-variable/geist": "^5.3.0",
33
+ "@fontsource-variable/geist-mono": "^5.3.0",
33
34
  "@types/node": "26.6.4",
34
35
  "@types/ws": "^8.18.1",
35
36
  "pixi.js": "^8.20.1",
@@ -37,7 +38,7 @@
37
38
  "typescript": "^7.0.2"
38
39
  },
39
40
  "optionalDependencies": {
40
- "@napi-rs/canvas": "^1.0.8"
41
+ "@napi-rs/canvas": "^1.0.13"
41
42
  },
42
43
  "scripts": {
43
44
  "build": "tsc -p tsconfig.build.json && tsdown",