@neta-art/cohub-cli 10.2.0 → 10.3.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
@@ -452,8 +452,16 @@ cohub apps resolve <appSlug> --owner <username> --space-slug <spaceSlug>
452
452
 
453
453
  Use `--json` for machine-readable output. `apps get`, `apps stats`, and `apps download` also accept `app://<username>/<space>/<app>` mention URIs. `apps stats` reports total, 24-hour, 7-day, and 30-day views with a source breakdown. Download restores newly published file and directory artifacts directly from the CDN with checksum verification. HTML files with companion assets are restored as directory bundles; Board and port apps are not downloadable. The resolve command remains available for explicit slug-based lookup.
454
454
 
455
- Realtime rooms use a published App's runtime identity, so they are available
456
- through `client.app.realtime` in the SDK rather than as CLI commands.
455
+ Agents can join hosted Rooms and attach realtime events or media:
456
+
457
+ ```bash
458
+ cohub rooms join '<invite>'
459
+ cohub rooms connect <room-id> --json
460
+ cohub rooms rtc <room-id> --channel chat --data-input -
461
+ cohub rooms rtc <room-id> --audio speech.pcm --output ./received
462
+ ```
463
+
464
+ RTC uses an on-demand Go/Pion helper, with external FFmpeg only when media conversion is needed. Encoded Ogg Opus and VP8 IVF inputs avoid re-encoding. Reception defaults to framed RTP archives; `--receive-format raw` decodes PCM/I420 for models. `--output -` writes binary frames to stdout and status to stderr. Recording is explicit, files are never overwritten, and the default receive quota is 1 GiB. Disconnecting keeps the hosted seat. See [Room RTC helper](../../apps/sandbox/cmd/cohub-rtc/README.md) for local builds, formats, migration details, and release prerequisites.
457
465
 
458
466
  ## Drive the Cohub UI
459
467
 
@@ -5772,10 +5772,10 @@ const colorString$1 = string().max(120).refine(isBoardColorString$1, "expected a
5772
5772
  const BoardColorSchema$1 = union([colorString$1, object({
5773
5773
  light: colorString$1,
5774
5774
  dark: colorString$1
5775
- }).strict()]);
5775
+ }).strict()]).describe("Palette token (follows the theme), CSS color, or { light, dark }.");
5776
5776
  const BoardStyleSchema$1 = object({
5777
5777
  fill: BoardColorSchema$1.optional(),
5778
- fillOpacity: ratio$1.optional(),
5778
+ fillOpacity: ratio$1.optional().describe("Fill opacity, independent of the item's opacity."),
5779
5779
  stroke: BoardColorSchema$1.optional(),
5780
5780
  strokeWidth: finite$1.min(0).max(64).optional(),
5781
5781
  dash: _enum([
@@ -5783,8 +5783,8 @@ const BoardStyleSchema$1 = object({
5783
5783
  "dashed",
5784
5784
  "dotted"
5785
5785
  ]).optional(),
5786
- radius: nonNegative$1.optional(),
5787
- trim: ratio$1.optional()
5786
+ radius: nonNegative$1.optional().describe("Corner radius in board units."),
5787
+ trim: ratio$1.optional().describe("Fraction of the outline drawn; animate 0 → 1 to draw it in.")
5788
5788
  }).strict();
5789
5789
  function isSafeBoardSourcePath$1(value) {
5790
5790
  return value.length > 0 && value.length <= 4096 && !value.startsWith("/") && !/^[a-z][a-z\d+.-]*:/i.test(value.trimStart()) && !value.includes("\\") && value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..");
@@ -5800,18 +5800,18 @@ function parseBoardMediaSource$1(src) {
5800
5800
  value: src
5801
5801
  } : null;
5802
5802
  }
5803
- const BoardMediaSourceSchema$1 = string().max(4096).refine((src) => parseBoardMediaSource$1(src) !== null, "src must be a public HTTP(S) URL or relative Space file path").describe("Prefer a stable public HTTP(S) URL; relative Space file paths remain supported.");
5803
+ const BoardMediaSourceSchema$1 = string().max(4096).refine((src) => parseBoardMediaSource$1(src) !== null, "src must be a public HTTP(S) URL or relative Space file path").describe("Prefer a public HTTP(S) URL; a Space path relative to the workspace root also works.");
5804
5804
  const BoardScriptSchema$1 = object({
5805
- src: srcSchema$1.refine((path) => /\.m?js$/.test(path), "script src must be a .js or .mjs module"),
5806
- params: record(string(), json()).default({}),
5807
- targets: record(string().min(1).max(80), BoardIdSchema$1).default({}),
5808
- seed: string().min(1).max(160).optional(),
5805
+ src: srcSchema$1.refine((path) => /\.m?js$/.test(path), "script src must be a .js or .mjs module").describe("A .js or .mjs Space file."),
5806
+ params: record(string(), json()).default({}).describe("Values the module reads as params."),
5807
+ targets: record(string().min(1).max(80), BoardIdSchema$1).default({}).describe("Named items the module can reach, as ctx.targets."),
5808
+ seed: string().min(1).max(160).optional().describe("Seed for ctx.random(); defaults to the item id."),
5809
5809
  enabled: boolean().default(true)
5810
- }).strict();
5810
+ }).strict().describe("A JavaScript module attached to the item; `cohub boards schema script` lists its API.");
5811
5811
  const itemEnvelope$1 = {
5812
5812
  script: BoardScriptSchema$1.optional(),
5813
- parent: BoardIdSchema$1.optional(),
5814
- z: finite$1.optional(),
5813
+ parent: BoardIdSchema$1.optional().describe("A frame id; position becomes relative to the frame, and the item moves with it."),
5814
+ z: finite$1.optional().describe("Order among siblings; new items go on top."),
5815
5815
  position: BoardVec2Schema$1.default({
5816
5816
  x: 0,
5817
5817
  y: 0
@@ -5821,7 +5821,7 @@ const itemEnvelope$1 = {
5821
5821
  origin: BoardVec2Schema$1.default({
5822
5822
  x: .5,
5823
5823
  y: .5
5824
- }),
5824
+ }).describe("Rotation and scale origin as fractions of the size."),
5825
5825
  opacity: ratio$1.default(1),
5826
5826
  style: BoardStyleSchema$1.default({}),
5827
5827
  locked: boolean().optional(),
@@ -5854,8 +5854,8 @@ const BoardFrameItemSchema$1 = object({
5854
5854
  type: literal("frame"),
5855
5855
  props: object({
5856
5856
  label: string().max(280).default(""),
5857
- decoration: _enum(["frame", "none"]).optional(),
5858
- clip: boolean().default(true)
5857
+ decoration: _enum(["frame", "none"]).optional().describe("none hides the title, border and background: an invisible group whose tracks move every child."),
5858
+ clip: boolean().default(true).describe("Hide children outside the frame.")
5859
5859
  }).strict().prefault({})
5860
5860
  }).strict();
5861
5861
  const BoardTextItemSchema$1 = object({
@@ -5865,12 +5865,12 @@ const BoardTextItemSchema$1 = object({
5865
5865
  text: string().default(""),
5866
5866
  fontSize: fontSize$1.default(24),
5867
5867
  fontWeight: finite$1.min(100).max(900).default(500),
5868
- font: string().min(1).max(120).default("sans"),
5868
+ font: string().min(1).max(120).default("sans").describe("sans, serif, mono, or a font family name."),
5869
5869
  align: _enum(BOARD_TEXT_ALIGNS$1).default("left"),
5870
5870
  lineHeight: finite$1.min(.5).max(4).default(4 / 3),
5871
- width: finite$1.positive().optional(),
5872
- reveal: ratio$1.default(1),
5873
- caret: boolean().default(false)
5871
+ width: finite$1.positive().optional().describe("Wrap width; the height follows the text."),
5872
+ reveal: ratio$1.default(1).describe("Fraction of the text shown; animate 0 → 1 to type it."),
5873
+ caret: boolean().default(false).describe("Blink a caret while the text is being revealed.")
5874
5874
  }).strict().prefault({})
5875
5875
  }).strict();
5876
5876
  const BoardShapeItemSchema$1 = object({
@@ -5878,7 +5878,7 @@ const BoardShapeItemSchema$1 = object({
5878
5878
  type: literal("shape"),
5879
5879
  props: object({
5880
5880
  geometry: _enum(BOARD_SHAPE_GEOMETRIES$1).default("rectangle"),
5881
- path: string().min(1).max(2e5).optional(),
5881
+ path: string().min(1).max(2e5).optional().describe("SVG path data for path geometry, fitted to the item size."),
5882
5882
  text: string().default(""),
5883
5883
  fontSize: fontSize$1.default(20),
5884
5884
  align: _enum(BOARD_TEXT_ALIGNS$1).default("center")
@@ -5890,12 +5890,12 @@ const BoardShapeItemSchema$1 = object({
5890
5890
  const BoardDrawPointSchema$1 = object({
5891
5891
  x: finite$1,
5892
5892
  y: finite$1,
5893
- p: ratio$1.default(.5)
5893
+ p: ratio$1.default(.5).describe("Pen pressure.")
5894
5894
  }).strict();
5895
5895
  const BoardDrawItemSchema$1 = object({
5896
5896
  ...unsized$1,
5897
5897
  type: literal("draw"),
5898
- props: object({ points: array(BoardDrawPointSchema$1).min(1).max(1e5) }).strict()
5898
+ props: object({ points: array(BoardDrawPointSchema$1).min(1).max(1e5).describe("Stroke points relative to position; the size follows them.") }).strict()
5899
5899
  }).strict();
5900
5900
  const BoardArrowEndSchema$1 = union([BoardVec2Schema$1, object({
5901
5901
  item: BoardIdSchema$1,
@@ -5914,9 +5914,9 @@ const BoardArrowEndSchema$1 = union([BoardVec2Schema$1, object({
5914
5914
  x: ratio$1,
5915
5915
  y: ratio$1
5916
5916
  }).strict()
5917
- ]).default("auto"),
5917
+ ]).default("auto").describe("auto uses the side facing the other end; or a side with an offset, or a point as fractions of the item."),
5918
5918
  port: string().min(1).max(120).optional()
5919
- }).strict()]);
5919
+ }).strict()]).describe("A point relative to position, or { item } to follow that item.");
5920
5920
  const BOARD_ARROW_ROUTES$1 = [
5921
5921
  "straight",
5922
5922
  "curve",
@@ -5929,13 +5929,13 @@ const BoardArrowItemSchema$1 = object({
5929
5929
  start: BoardArrowEndSchema$1,
5930
5930
  end: BoardArrowEndSchema$1,
5931
5931
  route: _enum(BOARD_ARROW_ROUTES$1).default("straight"),
5932
- bend: finite$1.min(-.85).max(.85).default(0),
5933
- waypoints: array(BoardVec2Schema$1).max(64).default([]),
5932
+ bend: finite$1.min(-.85).max(.85).default(0).describe("Sideways curve as a fraction of the length; the sign picks the side."),
5933
+ waypoints: array(BoardVec2Schema$1).max(64).default([]).describe("Points the route passes through."),
5934
5934
  arrowStart: boolean().default(false),
5935
5935
  arrowEnd: boolean().default(true),
5936
5936
  label: string().max(280).default(""),
5937
5937
  fontSize: fontSize$1.default(14),
5938
- relation: string().max(64).regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, "relation must be a lowercase slug").optional()
5938
+ relation: string().max(64).regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, "relation must be a lowercase slug").optional().describe("What the arrow means, as a slug such as triggers or depends-on.")
5939
5939
  }).strict()
5940
5940
  }).strict();
5941
5941
  const fileProps$1 = {
@@ -5951,26 +5951,26 @@ const fileProps$1 = {
5951
5951
  excerpt: string().optional(),
5952
5952
  coverPath: string().optional(),
5953
5953
  coverUrl: string().optional()
5954
- }).strict().optional()
5954
+ }).strict().describe("Cached facts for an instant preview; filled in when the media loads.").optional()
5955
5955
  };
5956
5956
  const BoardMediaOriginSchema$1 = object({
5957
5957
  taskRunId: string().min(1).max(160),
5958
5958
  artifactId: string().min(1).max(240)
5959
- }).strict();
5959
+ }).strict().describe("The generation run and result this media came from.");
5960
5960
  const mediaProps$1 = {
5961
5961
  ...fileProps$1,
5962
5962
  origin: BoardMediaOriginSchema$1.optional()
5963
5963
  };
5964
5964
  const timedFileProps$1 = {
5965
5965
  ...mediaProps$1,
5966
- time: nonNegative$1.optional()
5966
+ time: nonNegative$1.optional().describe("Playback position in milliseconds; animate props.time to play it on a timeline.")
5967
5967
  };
5968
5968
  const BoardCropSchema$1 = object({
5969
5969
  x: ratio$1,
5970
5970
  y: ratio$1,
5971
5971
  w: ratio$1,
5972
5972
  h: ratio$1
5973
- }).strict();
5973
+ }).strict().describe("Region shown, as fractions of the image. Without it the whole image fits inside size.");
5974
5974
  const BoardImageItemSchema$1 = object({
5975
5975
  ...boxed$1(640, 360),
5976
5976
  type: literal("image"),
@@ -6050,8 +6050,8 @@ const BoardTaskItemSchema$1 = object({
6050
6050
  ...boxed$1(420, 240),
6051
6051
  type: literal("task"),
6052
6052
  props: object({
6053
- taskRunId: string().min(1).max(160),
6054
- snapshot: BoardTaskSnapshotSchema$1.describe("Maintained by the server for tasks in the Board's Space; writers may omit it.")
6053
+ taskRunId: string().min(1).max(160).describe("The generation run shown; the server keeps the card's status and results up to date."),
6054
+ snapshot: BoardTaskSnapshotSchema$1.describe("Maintained by the server; omit it.")
6055
6055
  }).strict()
6056
6056
  }).strict();
6057
6057
  const BOARD_EFFECT_KINDS$1 = [
@@ -6074,8 +6074,8 @@ const BoardEffectItemSchema$1 = object({
6074
6074
  spread: finite$1.min(0).max(360).default(360),
6075
6075
  gravity: finite$1.default(0),
6076
6076
  intensity: nonNegative$1.max(10).default(1),
6077
- follow: BoardIdSchema$1.optional(),
6078
- seed: string().min(1).max(160).optional()
6077
+ follow: BoardIdSchema$1.optional().describe("trail: the item to follow."),
6078
+ seed: string().min(1).max(160).optional().describe("Makes procedural motion repeatable.")
6079
6079
  }).strict()
6080
6080
  }).strict();
6081
6081
  const BoardCustomItemSchema$1 = object({
@@ -6177,21 +6177,21 @@ function isBoardEase$1(value) {
6177
6177
  }
6178
6178
  const BoardEaseSchema$1 = string().max(120).refine(isBoardEase$1, "expected a CSS easing: linear, ease-in-out, cubic-bezier(…) or steps(…)");
6179
6179
  const BoardKeyframeSchema$1 = object({
6180
- at: nonNegative$1,
6180
+ at: nonNegative$1.describe("Time in milliseconds."),
6181
6181
  value: unknown(),
6182
- ease: BoardEaseSchema$1.optional()
6182
+ ease: BoardEaseSchema$1.optional().describe("Easing into this keyframe: a CSS keyword, cubic-bezier() or steps(); defaults to linear.")
6183
6183
  }).strict();
6184
6184
  const BoardTrackSchema$1 = object({
6185
- target: string().min(1).max(160),
6186
- property: string().min(1).max(200),
6187
- keyframes: array(BoardKeyframeSchema$1).min(1).max(1e5),
6188
- composite: _enum(["replace", "add"]).default("replace"),
6185
+ target: string().min(1).max(160).describe("An item id, camera, or another animation id (its time property nests that timeline)."),
6186
+ property: string().min(1).max(200).describe("JSON path in the target, e.g. position, opacity, style.fill, props.text."),
6187
+ keyframes: array(BoardKeyframeSchema$1).min(1).max(1e5).describe("Ordered by at. The value holds before the first and after the last keyframe."),
6188
+ composite: _enum(["replace", "add"]).default("replace").describe("replace: the latest-started track wins. add: positions and rotations add; scale and opacity multiply."),
6189
6189
  interpolation: _enum([
6190
6190
  "auto",
6191
6191
  "step",
6192
6192
  "spline"
6193
- ]).default("auto"),
6194
- orient: boolean().optional()
6193
+ ]).default("auto").describe("auto blends numbers, vectors and colors and steps other values; spline smooths position paths."),
6194
+ orient: boolean().optional().describe("Rotate the item along its path.")
6195
6195
  }).strict().superRefine((track, context) => {
6196
6196
  for (let index = 1; index < track.keyframes.length; index += 1) if ((track.keyframes[index]?.at ?? 0) < (track.keyframes[index - 1]?.at ?? 0)) context.addIssue({
6197
6197
  code: "custom",
@@ -6206,20 +6206,20 @@ const BoardTrackSchema$1 = object({
6206
6206
  const BoardMarkerSchema$1 = object({
6207
6207
  at: nonNegative$1,
6208
6208
  label: string().max(280).optional(),
6209
- pause: boolean().optional()
6209
+ pause: boolean().optional().describe("Hold shared playback here until next.")
6210
6210
  }).strict();
6211
6211
  const BoardAnimationHeaderSchema$1 = object({
6212
6212
  name: string().max(280).optional(),
6213
- duration: finite$1.positive().max(864e5),
6213
+ duration: finite$1.positive().max(864e5).describe("Milliseconds; a new animation without it ends at its last keyframe."),
6214
6214
  play: _enum([
6215
6215
  "manual",
6216
6216
  "auto",
6217
6217
  "always"
6218
- ]).default("manual"),
6218
+ ]).default("manual").describe("manual: only shared playback starts it. auto: plays when the Board opens. always: loops continuously."),
6219
6219
  delay: nonNegative$1.default(0),
6220
6220
  loop: boolean().default(false),
6221
- end: _enum(["hold", "reset"]).default("hold"),
6222
- markers: array(BoardMarkerSchema$1).max(1e4).default([])
6221
+ end: _enum(["hold", "reset"]).default("hold").describe("hold keeps the last frame; reset returns to the start."),
6222
+ markers: array(BoardMarkerSchema$1).max(1e4).default([]).describe("Labeled points on this timeline; a nested animation's markers do not carry over.")
6223
6223
  }).strict();
6224
6224
  BoardAnimationHeaderSchema$1.extend({ tracks: record(BoardKeySchema$1, BoardTrackSchema$1).default({}) }).strict();
6225
6225
  const BOARD_ENTER_PRESETS$1 = [
@@ -6237,7 +6237,7 @@ const BoardBackgroundSchema$1 = object({
6237
6237
  "image"
6238
6238
  ]).default("dots"),
6239
6239
  color: BoardColorSchema$1.optional(),
6240
- imageUrl: string().url().max(4096).optional(),
6240
+ imageUrl: string().url().max(4096).optional().describe("Background image for kind image."),
6241
6241
  fit: _enum([
6242
6242
  "cover",
6243
6243
  "contain",
@@ -6249,13 +6249,13 @@ const BoardSettingsSchema$1 = object({
6249
6249
  title: string().min(1).max(255).optional(),
6250
6250
  background: BoardBackgroundSchema$1.default({ kind: "dots" }),
6251
6251
  grid: object({
6252
- visible: boolean().default(false),
6252
+ visible: boolean().default(false).describe("Show the grid overlay."),
6253
6253
  size: finite$1.min(4).max(512).default(24)
6254
6254
  }).strict().optional(),
6255
6255
  enter: object({
6256
6256
  preset: _enum(BOARD_ENTER_PRESETS$1),
6257
6257
  duration: finite$1.positive().max(1e4).optional()
6258
- }).strict().optional()
6258
+ }).strict().optional().describe("Motion for items as they are added.")
6259
6259
  }).strict();
6260
6260
  const patchObject$1 = record(string(), unknown());
6261
6261
  const BoardPatchSchema$1 = object({
@@ -30341,10 +30341,10 @@ const colorString = string().max(120).refine(isBoardColorString, "expected a pal
30341
30341
  const BoardColorSchema = union([colorString, object({
30342
30342
  light: colorString,
30343
30343
  dark: colorString
30344
- }).strict()]);
30344
+ }).strict()]).describe("Palette token (follows the theme), CSS color, or { light, dark }.");
30345
30345
  const BoardStyleSchema = object({
30346
30346
  fill: BoardColorSchema.optional(),
30347
- fillOpacity: ratio.optional(),
30347
+ fillOpacity: ratio.optional().describe("Fill opacity, independent of the item's opacity."),
30348
30348
  stroke: BoardColorSchema.optional(),
30349
30349
  strokeWidth: finite.min(0).max(64).optional(),
30350
30350
  dash: _enum([
@@ -30352,8 +30352,8 @@ const BoardStyleSchema = object({
30352
30352
  "dashed",
30353
30353
  "dotted"
30354
30354
  ]).optional(),
30355
- radius: nonNegative.optional(),
30356
- trim: ratio.optional()
30355
+ radius: nonNegative.optional().describe("Corner radius in board units."),
30356
+ trim: ratio.optional().describe("Fraction of the outline drawn; animate 0 → 1 to draw it in.")
30357
30357
  }).strict();
30358
30358
  function isSafeBoardSourcePath(value) {
30359
30359
  return value.length > 0 && value.length <= 4096 && !value.startsWith("/") && !/^[a-z][a-z\d+.-]*:/i.test(value.trimStart()) && !value.includes("\\") && value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..");
@@ -30369,18 +30369,18 @@ function parseBoardMediaSource(src) {
30369
30369
  value: src
30370
30370
  } : null;
30371
30371
  }
30372
- const BoardMediaSourceSchema = string().max(4096).refine((src) => parseBoardMediaSource(src) !== null, "src must be a public HTTP(S) URL or relative Space file path").describe("Prefer a stable public HTTP(S) URL; relative Space file paths remain supported.");
30372
+ const BoardMediaSourceSchema = string().max(4096).refine((src) => parseBoardMediaSource(src) !== null, "src must be a public HTTP(S) URL or relative Space file path").describe("Prefer a public HTTP(S) URL; a Space path relative to the workspace root also works.");
30373
30373
  const BoardScriptSchema = object({
30374
- src: srcSchema.refine((path) => /\.m?js$/.test(path), "script src must be a .js or .mjs module"),
30375
- params: record(string(), json()).default({}),
30376
- targets: record(string().min(1).max(80), BoardIdSchema).default({}),
30377
- seed: string().min(1).max(160).optional(),
30374
+ src: srcSchema.refine((path) => /\.m?js$/.test(path), "script src must be a .js or .mjs module").describe("A .js or .mjs Space file."),
30375
+ params: record(string(), json()).default({}).describe("Values the module reads as params."),
30376
+ targets: record(string().min(1).max(80), BoardIdSchema).default({}).describe("Named items the module can reach, as ctx.targets."),
30377
+ seed: string().min(1).max(160).optional().describe("Seed for ctx.random(); defaults to the item id."),
30378
30378
  enabled: boolean().default(true)
30379
- }).strict();
30379
+ }).strict().describe("A JavaScript module attached to the item; `cohub boards schema script` lists its API.");
30380
30380
  const itemEnvelope = {
30381
30381
  script: BoardScriptSchema.optional(),
30382
- parent: BoardIdSchema.optional(),
30383
- z: finite.optional(),
30382
+ parent: BoardIdSchema.optional().describe("A frame id; position becomes relative to the frame, and the item moves with it."),
30383
+ z: finite.optional().describe("Order among siblings; new items go on top."),
30384
30384
  position: BoardVec2Schema.default({
30385
30385
  x: 0,
30386
30386
  y: 0
@@ -30390,7 +30390,7 @@ const itemEnvelope = {
30390
30390
  origin: BoardVec2Schema.default({
30391
30391
  x: .5,
30392
30392
  y: .5
30393
- }),
30393
+ }).describe("Rotation and scale origin as fractions of the size."),
30394
30394
  opacity: ratio.default(1),
30395
30395
  style: BoardStyleSchema.default({}),
30396
30396
  locked: boolean().optional(),
@@ -30423,8 +30423,8 @@ object({
30423
30423
  type: literal("frame"),
30424
30424
  props: object({
30425
30425
  label: string().max(280).default(""),
30426
- decoration: _enum(["frame", "none"]).optional(),
30427
- clip: boolean().default(true)
30426
+ decoration: _enum(["frame", "none"]).optional().describe("none hides the title, border and background: an invisible group whose tracks move every child."),
30427
+ clip: boolean().default(true).describe("Hide children outside the frame.")
30428
30428
  }).strict().prefault({})
30429
30429
  }).strict();
30430
30430
  object({
@@ -30434,12 +30434,12 @@ object({
30434
30434
  text: string().default(""),
30435
30435
  fontSize: fontSize.default(24),
30436
30436
  fontWeight: finite.min(100).max(900).default(500),
30437
- font: string().min(1).max(120).default("sans"),
30437
+ font: string().min(1).max(120).default("sans").describe("sans, serif, mono, or a font family name."),
30438
30438
  align: _enum(BOARD_TEXT_ALIGNS).default("left"),
30439
30439
  lineHeight: finite.min(.5).max(4).default(4 / 3),
30440
- width: finite.positive().optional(),
30441
- reveal: ratio.default(1),
30442
- caret: boolean().default(false)
30440
+ width: finite.positive().optional().describe("Wrap width; the height follows the text."),
30441
+ reveal: ratio.default(1).describe("Fraction of the text shown; animate 0 → 1 to type it."),
30442
+ caret: boolean().default(false).describe("Blink a caret while the text is being revealed.")
30443
30443
  }).strict().prefault({})
30444
30444
  }).strict();
30445
30445
  object({
@@ -30447,7 +30447,7 @@ object({
30447
30447
  type: literal("shape"),
30448
30448
  props: object({
30449
30449
  geometry: _enum(BOARD_SHAPE_GEOMETRIES).default("rectangle"),
30450
- path: string().min(1).max(2e5).optional(),
30450
+ path: string().min(1).max(2e5).optional().describe("SVG path data for path geometry, fitted to the item size."),
30451
30451
  text: string().default(""),
30452
30452
  fontSize: fontSize.default(20),
30453
30453
  align: _enum(BOARD_TEXT_ALIGNS).default("center")
@@ -30459,12 +30459,12 @@ object({
30459
30459
  const BoardDrawPointSchema = object({
30460
30460
  x: finite,
30461
30461
  y: finite,
30462
- p: ratio.default(.5)
30462
+ p: ratio.default(.5).describe("Pen pressure.")
30463
30463
  }).strict();
30464
30464
  object({
30465
30465
  ...unsized,
30466
30466
  type: literal("draw"),
30467
- props: object({ points: array(BoardDrawPointSchema).min(1).max(1e5) }).strict()
30467
+ props: object({ points: array(BoardDrawPointSchema).min(1).max(1e5).describe("Stroke points relative to position; the size follows them.") }).strict()
30468
30468
  }).strict();
30469
30469
  const BoardArrowEndSchema = union([BoardVec2Schema, object({
30470
30470
  item: BoardIdSchema,
@@ -30483,9 +30483,9 @@ const BoardArrowEndSchema = union([BoardVec2Schema, object({
30483
30483
  x: ratio,
30484
30484
  y: ratio
30485
30485
  }).strict()
30486
- ]).default("auto"),
30486
+ ]).default("auto").describe("auto uses the side facing the other end; or a side with an offset, or a point as fractions of the item."),
30487
30487
  port: string().min(1).max(120).optional()
30488
- }).strict()]);
30488
+ }).strict()]).describe("A point relative to position, or { item } to follow that item.");
30489
30489
  const BOARD_ARROW_ROUTES = [
30490
30490
  "straight",
30491
30491
  "curve",
@@ -30498,13 +30498,13 @@ object({
30498
30498
  start: BoardArrowEndSchema,
30499
30499
  end: BoardArrowEndSchema,
30500
30500
  route: _enum(BOARD_ARROW_ROUTES).default("straight"),
30501
- bend: finite.min(-.85).max(.85).default(0),
30502
- waypoints: array(BoardVec2Schema).max(64).default([]),
30501
+ bend: finite.min(-.85).max(.85).default(0).describe("Sideways curve as a fraction of the length; the sign picks the side."),
30502
+ waypoints: array(BoardVec2Schema).max(64).default([]).describe("Points the route passes through."),
30503
30503
  arrowStart: boolean().default(false),
30504
30504
  arrowEnd: boolean().default(true),
30505
30505
  label: string().max(280).default(""),
30506
30506
  fontSize: fontSize.default(14),
30507
- relation: string().max(64).regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, "relation must be a lowercase slug").optional()
30507
+ relation: string().max(64).regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, "relation must be a lowercase slug").optional().describe("What the arrow means, as a slug such as triggers or depends-on.")
30508
30508
  }).strict()
30509
30509
  }).strict();
30510
30510
  const fileProps = {
@@ -30520,26 +30520,26 @@ const fileProps = {
30520
30520
  excerpt: string().optional(),
30521
30521
  coverPath: string().optional(),
30522
30522
  coverUrl: string().optional()
30523
- }).strict().optional()
30523
+ }).strict().describe("Cached facts for an instant preview; filled in when the media loads.").optional()
30524
30524
  };
30525
30525
  const BoardMediaOriginSchema = object({
30526
30526
  taskRunId: string().min(1).max(160),
30527
30527
  artifactId: string().min(1).max(240)
30528
- }).strict();
30528
+ }).strict().describe("The generation run and result this media came from.");
30529
30529
  const mediaProps = {
30530
30530
  ...fileProps,
30531
30531
  origin: BoardMediaOriginSchema.optional()
30532
30532
  };
30533
30533
  const timedFileProps = {
30534
30534
  ...mediaProps,
30535
- time: nonNegative.optional()
30535
+ time: nonNegative.optional().describe("Playback position in milliseconds; animate props.time to play it on a timeline.")
30536
30536
  };
30537
30537
  const BoardCropSchema = object({
30538
30538
  x: ratio,
30539
30539
  y: ratio,
30540
30540
  w: ratio,
30541
30541
  h: ratio
30542
- }).strict();
30542
+ }).strict().describe("Region shown, as fractions of the image. Without it the whole image fits inside size.");
30543
30543
  object({
30544
30544
  ...boxed(640, 360),
30545
30545
  type: literal("image"),
@@ -30619,8 +30619,8 @@ object({
30619
30619
  ...boxed(420, 240),
30620
30620
  type: literal("task"),
30621
30621
  props: object({
30622
- taskRunId: string().min(1).max(160),
30623
- snapshot: BoardTaskSnapshotSchema.describe("Maintained by the server for tasks in the Board's Space; writers may omit it.")
30622
+ taskRunId: string().min(1).max(160).describe("The generation run shown; the server keeps the card's status and results up to date."),
30623
+ snapshot: BoardTaskSnapshotSchema.describe("Maintained by the server; omit it.")
30624
30624
  }).strict()
30625
30625
  }).strict();
30626
30626
  const BOARD_EFFECT_KINDS = [
@@ -30643,8 +30643,8 @@ object({
30643
30643
  spread: finite.min(0).max(360).default(360),
30644
30644
  gravity: finite.default(0),
30645
30645
  intensity: nonNegative.max(10).default(1),
30646
- follow: BoardIdSchema.optional(),
30647
- seed: string().min(1).max(160).optional()
30646
+ follow: BoardIdSchema.optional().describe("trail: the item to follow."),
30647
+ seed: string().min(1).max(160).optional().describe("Makes procedural motion repeatable.")
30648
30648
  }).strict()
30649
30649
  }).strict();
30650
30650
  object({
@@ -30684,21 +30684,21 @@ function isBoardEase(value) {
30684
30684
  }
30685
30685
  const BoardEaseSchema = string().max(120).refine(isBoardEase, "expected a CSS easing: linear, ease-in-out, cubic-bezier(…) or steps(…)");
30686
30686
  const BoardKeyframeSchema = object({
30687
- at: nonNegative,
30687
+ at: nonNegative.describe("Time in milliseconds."),
30688
30688
  value: unknown(),
30689
- ease: BoardEaseSchema.optional()
30689
+ ease: BoardEaseSchema.optional().describe("Easing into this keyframe: a CSS keyword, cubic-bezier() or steps(); defaults to linear.")
30690
30690
  }).strict();
30691
30691
  const BoardTrackSchema = object({
30692
- target: string().min(1).max(160),
30693
- property: string().min(1).max(200),
30694
- keyframes: array(BoardKeyframeSchema).min(1).max(1e5),
30695
- composite: _enum(["replace", "add"]).default("replace"),
30692
+ target: string().min(1).max(160).describe("An item id, camera, or another animation id (its time property nests that timeline)."),
30693
+ property: string().min(1).max(200).describe("JSON path in the target, e.g. position, opacity, style.fill, props.text."),
30694
+ keyframes: array(BoardKeyframeSchema).min(1).max(1e5).describe("Ordered by at. The value holds before the first and after the last keyframe."),
30695
+ composite: _enum(["replace", "add"]).default("replace").describe("replace: the latest-started track wins. add: positions and rotations add; scale and opacity multiply."),
30696
30696
  interpolation: _enum([
30697
30697
  "auto",
30698
30698
  "step",
30699
30699
  "spline"
30700
- ]).default("auto"),
30701
- orient: boolean().optional()
30700
+ ]).default("auto").describe("auto blends numbers, vectors and colors and steps other values; spline smooths position paths."),
30701
+ orient: boolean().optional().describe("Rotate the item along its path.")
30702
30702
  }).strict().superRefine((track, context) => {
30703
30703
  for (let index = 1; index < track.keyframes.length; index += 1) if ((track.keyframes[index]?.at ?? 0) < (track.keyframes[index - 1]?.at ?? 0)) context.addIssue({
30704
30704
  code: "custom",
@@ -30713,20 +30713,20 @@ const BoardTrackSchema = object({
30713
30713
  const BoardMarkerSchema = object({
30714
30714
  at: nonNegative,
30715
30715
  label: string().max(280).optional(),
30716
- pause: boolean().optional()
30716
+ pause: boolean().optional().describe("Hold shared playback here until next.")
30717
30717
  }).strict();
30718
30718
  object({
30719
30719
  name: string().max(280).optional(),
30720
- duration: finite.positive().max(864e5),
30720
+ duration: finite.positive().max(864e5).describe("Milliseconds; a new animation without it ends at its last keyframe."),
30721
30721
  play: _enum([
30722
30722
  "manual",
30723
30723
  "auto",
30724
30724
  "always"
30725
- ]).default("manual"),
30725
+ ]).default("manual").describe("manual: only shared playback starts it. auto: plays when the Board opens. always: loops continuously."),
30726
30726
  delay: nonNegative.default(0),
30727
30727
  loop: boolean().default(false),
30728
- end: _enum(["hold", "reset"]).default("hold"),
30729
- markers: array(BoardMarkerSchema).max(1e4).default([])
30728
+ end: _enum(["hold", "reset"]).default("hold").describe("hold keeps the last frame; reset returns to the start."),
30729
+ markers: array(BoardMarkerSchema).max(1e4).default([]).describe("Labeled points on this timeline; a nested animation's markers do not carry over.")
30730
30730
  }).strict().extend({ tracks: record(BoardKeySchema, BoardTrackSchema).default({}) }).strict();
30731
30731
  const BOARD_ENTER_PRESETS = [
30732
30732
  "fade-in",
@@ -30743,7 +30743,7 @@ const BoardBackgroundSchema = object({
30743
30743
  "image"
30744
30744
  ]).default("dots"),
30745
30745
  color: BoardColorSchema.optional(),
30746
- imageUrl: string().url().max(4096).optional(),
30746
+ imageUrl: string().url().max(4096).optional().describe("Background image for kind image."),
30747
30747
  fit: _enum([
30748
30748
  "cover",
30749
30749
  "contain",
@@ -30755,13 +30755,13 @@ object({
30755
30755
  title: string().min(1).max(255).optional(),
30756
30756
  background: BoardBackgroundSchema.default({ kind: "dots" }),
30757
30757
  grid: object({
30758
- visible: boolean().default(false),
30758
+ visible: boolean().default(false).describe("Show the grid overlay."),
30759
30759
  size: finite.min(4).max(512).default(24)
30760
30760
  }).strict().optional(),
30761
30761
  enter: object({
30762
30762
  preset: _enum(BOARD_ENTER_PRESETS),
30763
30763
  duration: finite.positive().max(1e4).optional()
30764
- }).strict().optional()
30764
+ }).strict().optional().describe("Motion for items as they are added.")
30765
30765
  }).strict();
30766
30766
  const patchObject = record(string(), unknown());
30767
30767
  object({
@@ -2,7 +2,7 @@ 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, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
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
7
  import { formatBoardTime, parseBoardTime, parseBoardTimes } from "../board-time.js";
8
8
  import { createClient, createRealtimeClient } from "../client.js";
@@ -283,13 +283,18 @@ export function registerBoards(program) {
283
283
  .description("Read, write, animate and export Boards (by id or .board path)")
284
284
  .addHelpText("after", `
285
285
  A Board is one JSON document: { board, items, animations }.
286
- get read it
287
- apply write it with a JSON Merge Patch; null deletes
288
- schema the schema of any part, and what tracks can animate
289
- preset tracks for a preset motion, ready to apply
290
- history list versions, or restore one
291
- export render an image, a frame sequence or a video
292
- examples starter documents
286
+ get read it
287
+ apply write it with a JSON Merge Patch; null deletes
288
+ schema the fields of any part and what they mean; also the script API
289
+ examples starter documents and a custom script module
290
+ preset tracks for a preset motion, ready to apply
291
+ sequence join chapter animations into one timeline
292
+ path sample a polyline into a position track
293
+ inspect measure items and report layout hints
294
+ export render an image, a frame sequence or a video
295
+ play shared playback; also pause, resume, next, seek, stop
296
+ watch stream changes and playback
297
+ history list versions, or restore one
293
298
  Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
294
299
  withJson(boards.command("create <path>")
295
300
  .description("Create a Board file, optionally with an initial document")
@@ -444,9 +449,9 @@ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
444
449
  boards.command("schema [target]")
445
450
  .description("Print JSON Schema for a part of a Board")
446
451
  .addHelpText("after", `
447
- With a target: one item type, or animation, track, or camera.
448
- Targets: ${BOARD_SCHEMA_TARGETS.join(", ")}
449
- Without a target: units, item types, colors and every animatable property.`)
452
+ Without a target: units, item types, colors and every animatable property.
453
+ With a target: its fields and what they mean; script also lists the module API.
454
+ Targets: ${BOARD_SCHEMA_TARGETS.join(", ")}`)
450
455
  .action((target) => {
451
456
  try {
452
457
  if (!target)
@@ -454,7 +459,7 @@ Without a target: units, item types, colors and every animatable property.`)
454
459
  const schema = boardJsonSchema(target);
455
460
  if (!schema)
456
461
  throw new Error(`Unknown target ${target}; expected ${BOARD_SCHEMA_TARGETS.join(", ")}.`);
457
- outJson(schema);
462
+ outJson(target === "script" ? { ...schema, api: BOARD_SCRIPT_API } : schema);
458
463
  }
459
464
  catch (cause) {
460
465
  handleHttp(cause);
@@ -0,0 +1,17 @@
1
+ import type { Readable } from "node:stream";
2
+ type Input = {
3
+ op: "publish" | "send";
4
+ type: string;
5
+ data: unknown;
6
+ } | {
7
+ op: "presence";
8
+ presence: Record<string, unknown> | null;
9
+ };
10
+ export declare function parseRoomInput(line: string): Input;
11
+ export declare function readRoomLines(input: Readable, maxBytes?: number): AsyncGenerator<string>;
12
+ export declare function connectToRoom(roomId: string, options: {
13
+ after?: string;
14
+ input?: boolean;
15
+ json: boolean;
16
+ }): Promise<void>;
17
+ export {};
@@ -0,0 +1,140 @@
1
+ import { createRealtimeClient } from "../client.js";
2
+ const MAX_LINE_BYTES = 20 * 1024;
3
+ const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024;
4
+ export function parseRoomInput(line) {
5
+ if (Buffer.byteLength(line) > MAX_LINE_BYTES)
6
+ throw new Error("Room input line is too large");
7
+ let value;
8
+ try {
9
+ value = JSON.parse(line);
10
+ }
11
+ catch {
12
+ throw new Error("Room input must be JSON");
13
+ }
14
+ if (!value || typeof value !== "object" || Array.isArray(value))
15
+ throw new Error("Room input must be an object");
16
+ const input = value;
17
+ if (input.op === "presence" && (input.presence === null || typeof input.presence === "object" && !Array.isArray(input.presence))) {
18
+ return { op: "presence", presence: input.presence };
19
+ }
20
+ if ((input.op === "publish" || input.op === "send") && typeof input.type === "string" && "data" in input) {
21
+ return { op: input.op, type: input.type, data: input.data };
22
+ }
23
+ throw new Error("Use {\"op\":\"publish|send\",\"type\":\"name\",\"data\":...} or {\"op\":\"presence\",\"presence\":...}");
24
+ }
25
+ export async function* readRoomLines(input, maxBytes = MAX_LINE_BYTES) {
26
+ let pending = Buffer.alloc(0);
27
+ for await (const chunk of input) {
28
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
+ let start = 0;
30
+ for (;;) {
31
+ const end = bytes.indexOf(10, start);
32
+ const part = bytes.subarray(start, end < 0 ? bytes.length : end);
33
+ if (pending.length + part.length > maxBytes)
34
+ throw new Error("Room input line is too large");
35
+ pending = pending.length ? Buffer.concat([pending, part]) : part;
36
+ if (end < 0)
37
+ break;
38
+ yield pending.toString("utf8").replace(/\r$/, "");
39
+ pending = Buffer.alloc(0);
40
+ start = end + 1;
41
+ }
42
+ }
43
+ if (pending.length)
44
+ yield pending.toString("utf8");
45
+ }
46
+ export async function connectToRoom(roomId, options) {
47
+ const after = options.after === undefined ? undefined : Number(options.after);
48
+ if (after !== undefined && (!Number.isSafeInteger(after) || after < 0))
49
+ throw new Error("--after must be a non-negative sequence number");
50
+ const client = createRealtimeClient();
51
+ let room = null;
52
+ let stopped = false;
53
+ let finish;
54
+ const done = new Promise((resolve) => { finish = resolve; });
55
+ const stop = () => {
56
+ if (stopped)
57
+ return;
58
+ stopped = true;
59
+ finish();
60
+ };
61
+ const write = (kind, value) => {
62
+ if (stopped)
63
+ return;
64
+ if (process.stdout.writableLength > MAX_OUTPUT_BUFFER_BYTES) {
65
+ process.stderr.write("Room output is too slow; disconnecting.\n");
66
+ process.exitCode = 1;
67
+ stop();
68
+ return;
69
+ }
70
+ process.stdout.write(options.json ? `${JSON.stringify({ kind, ...value })}\n` : `${kind}: ${JSON.stringify(value)}\n`);
71
+ };
72
+ const onSignal = () => { stop(); void room?.disconnect().catch(() => undefined); void client.disconnect(); };
73
+ process.once("SIGINT", onSignal);
74
+ process.once("SIGTERM", onSignal);
75
+ let inputTask = null;
76
+ try {
77
+ room = await client.rooms.connect(roomId, {
78
+ after,
79
+ onRoom: (active) => {
80
+ room = active;
81
+ if (stopped) {
82
+ void active.disconnect();
83
+ return;
84
+ }
85
+ active.subscribeAll((event) => write("event", { event }));
86
+ active.onMembersChanged((members) => write("members", { members }));
87
+ active.onOutOfSync((from, to) => write("gap", { from, to }));
88
+ active.onSendError((failure) => write("error", { message: failure.message }));
89
+ active.onStateChange((state) => {
90
+ write("state", { state });
91
+ if (state === "closed" || state === "expired")
92
+ stop();
93
+ });
94
+ },
95
+ });
96
+ if (!stopped)
97
+ write("connected", { roomId, participantId: room.participantId, sequence: room.sequence });
98
+ if (!stopped && options.input) {
99
+ const active = room;
100
+ inputTask = (async () => {
101
+ let lineNumber = 0;
102
+ for await (const line of readRoomLines(process.stdin)) {
103
+ if (stopped)
104
+ break;
105
+ lineNumber++;
106
+ if (!line.trim())
107
+ continue;
108
+ try {
109
+ const input = parseRoomInput(line);
110
+ if (input.op === "presence")
111
+ await active.setPresence(input.presence);
112
+ else if (input.op === "send")
113
+ active.send(input.type, input.data);
114
+ else {
115
+ const result = await active.publish(input.type, input.data);
116
+ write("ack", { line: lineNumber, sequence: result.sequence, eventId: result.eventId });
117
+ }
118
+ }
119
+ catch (cause) {
120
+ write("input_error", { line: lineNumber, message: cause instanceof Error ? cause.message : String(cause) });
121
+ }
122
+ }
123
+ })().catch((cause) => {
124
+ write("input_error", { message: cause instanceof Error ? cause.message : String(cause) });
125
+ process.exitCode = 1;
126
+ stop();
127
+ });
128
+ }
129
+ await done;
130
+ }
131
+ finally {
132
+ if (options.input)
133
+ process.stdin.destroy();
134
+ await room?.disconnect().catch(() => undefined);
135
+ await client.disconnect();
136
+ await inputTask?.catch(() => undefined);
137
+ process.removeListener("SIGINT", onSignal);
138
+ process.removeListener("SIGTERM", onSignal);
139
+ }
140
+ }
@@ -0,0 +1,2 @@
1
+ export declare const RTC_HELPER_VERSION: string;
2
+ export declare function ensureRtcBinary(local?: string): Promise<string>;
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { Readable, Transform } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { createGunzip } from "node:zlib";
9
+ // Pin a global release tag after its helper artifacts are published.
10
+ export const RTC_HELPER_VERSION = "v2.67.0";
11
+ const MAX_BINARY_BYTES = 128 * 1024 * 1024;
12
+ async function digest(path) {
13
+ const hash = createHash("sha256");
14
+ await pipeline(createReadStream(path), hash);
15
+ return hash.digest("hex");
16
+ }
17
+ async function readText(response, limit) {
18
+ if (!response.body)
19
+ throw new Error("Empty RTC helper response");
20
+ const reader = response.body.getReader();
21
+ const parts = [];
22
+ let bytes = 0;
23
+ try {
24
+ for (;;) {
25
+ const { done, value } = await reader.read();
26
+ if (done)
27
+ break;
28
+ bytes += value.length;
29
+ if (bytes > limit)
30
+ throw new Error("RTC helper metadata exceeds the size limit");
31
+ parts.push(value);
32
+ }
33
+ }
34
+ finally {
35
+ await reader.cancel();
36
+ }
37
+ return Buffer.concat(parts).toString("utf8");
38
+ }
39
+ export async function ensureRtcBinary(local) {
40
+ const override = local || process.env.COHUB_RTC_BIN?.trim();
41
+ if (override) {
42
+ if (!(await lstat(override)).isFile())
43
+ throw new Error("RTC helper must be a regular file");
44
+ return resolve(override);
45
+ }
46
+ const platform = { linux: "linux", darwin: "darwin" }[process.platform];
47
+ const arch = { x64: "amd64", arm64: "arm64" }[process.arch];
48
+ if (!platform || !arch)
49
+ throw new Error("Unsupported RTC helper platform; use --helper with a local binary");
50
+ const version = process.env.COHUB_RTC_VERSION?.trim() || RTC_HELPER_VERSION;
51
+ if (!version)
52
+ throw new Error("No RTC helper release is pinned yet; use --helper or COHUB_RTC_VERSION with a published global release");
53
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(version))
54
+ throw new Error("Invalid RTC helper version");
55
+ const directory = join(homedir(), ".cache", "cohub", "rtc", version, `${platform}-${arch}`);
56
+ const binary = join(directory, "cohub-rtc");
57
+ const checksum = `${binary}.sha256`;
58
+ const expected = await readFile(checksum, "utf8").catch(() => "");
59
+ if (/^[a-f0-9]{64}$/.test(expected) && (await lstat(binary).catch(() => null))?.isFile() && await digest(binary) === expected)
60
+ return binary;
61
+ await mkdir(directory, { recursive: true });
62
+ const temporary = await mkdtemp(join(directory, ".download-"));
63
+ const base = (process.env.COHUB_RTC_CDN_BASE_URL?.trim() || "https://public.cohub.live/rtc").replace(/\/+$/, "");
64
+ const url = `${base}/${version}/cohub-rtc_${platform}_${arch}.gz`;
65
+ const signal = AbortSignal.timeout(120_000);
66
+ try {
67
+ const manifest = await fetch(`${url}.sha256`, { signal });
68
+ if (!manifest.ok)
69
+ throw new Error(`RTC helper ${version} is unavailable (${manifest.status}); use --helper with a locally built cohub-rtc`);
70
+ const text = await readText(manifest, 1024);
71
+ const expectedArchive = text.trim().split(/\s+/)[0];
72
+ if (!expectedArchive || !/^[a-f0-9]{64}$/.test(expectedArchive))
73
+ throw new Error("Invalid RTC helper checksum");
74
+ const response = await fetch(url, { signal });
75
+ if (!response.ok || !response.body)
76
+ throw new Error(`RTC helper download failed (${response.status})`);
77
+ let bytes = 0;
78
+ const bound = () => new Transform({ transform(chunk, _encoding, done) {
79
+ bytes += chunk.length;
80
+ done(bytes > MAX_BINARY_BYTES ? new Error("RTC helper exceeds the size limit") : null, chunk);
81
+ } });
82
+ const archive = join(temporary, "archive.gz");
83
+ await pipeline(Readable.fromWeb(response.body), bound(), createWriteStream(archive, { flags: "wx" }), { signal });
84
+ if (await digest(archive) !== expectedArchive)
85
+ throw new Error("RTC helper checksum mismatch");
86
+ for (const name of ["LICENSE", "NOTICE"]) {
87
+ const response = await fetch(`${base}/${version}/${name}`, { signal });
88
+ if (!response.ok)
89
+ throw new Error(`RTC helper ${name} is unavailable (${response.status})`);
90
+ const text = await readText(response, 64 * 1024);
91
+ await writeFile(join(temporary, name), text, { flag: "wx" });
92
+ await rename(join(temporary, name), join(directory, name));
93
+ }
94
+ const extracted = join(temporary, "cohub-rtc");
95
+ bytes = 0;
96
+ await pipeline(createReadStream(archive), createGunzip(), bound(), createWriteStream(extracted, { flags: "wx", mode: 0o700 }), { signal });
97
+ await chmod(extracted, 0o700);
98
+ const hash = await digest(extracted);
99
+ await writeFile(join(temporary, "checksum"), hash, { flag: "wx" });
100
+ await rename(extracted, binary);
101
+ await rename(join(temporary, "checksum"), checksum);
102
+ return binary;
103
+ }
104
+ finally {
105
+ await rm(temporary, { recursive: true, force: true });
106
+ }
107
+ }
@@ -0,0 +1,21 @@
1
+ export type RtcOptions = {
2
+ helper?: string;
3
+ audio?: string;
4
+ video?: string;
5
+ audioFormat: "pcm" | "opus";
6
+ videoFormat: "i420" | "vp8";
7
+ size?: string;
8
+ fps: string;
9
+ rate: string;
10
+ channels: string;
11
+ output?: string;
12
+ receiveFormat: "rtp" | "raw";
13
+ receiveSize: string;
14
+ maxBytes: string;
15
+ participant?: string;
16
+ channel?: string;
17
+ delivery: "reliable" | "latest";
18
+ dataInput?: string;
19
+ json?: boolean;
20
+ };
21
+ export declare function runRoomRtc(roomId: string, options: RtcOptions): Promise<void>;
@@ -0,0 +1,171 @@
1
+ import { spawn } from "node:child_process";
2
+ import { HttpError } from "@neta-art/cohub";
3
+ import { createRealtimeClient } from "../client.js";
4
+ import { readRoomLines } from "./rooms-connect.js";
5
+ import { ensureRtcBinary } from "./rooms-rtc-binary.js";
6
+ export async function runRoomRtc(roomId, options) {
7
+ if (options.delivery !== "reliable" && options.delivery !== "latest")
8
+ throw new Error("--delivery must be reliable or latest");
9
+ if (options.dataInput && !options.channel)
10
+ throw new Error("--data-input requires --channel");
11
+ if ([options.audio, options.video, options.dataInput].filter((value) => value === "-").length > 1)
12
+ throw new Error("Only one input can use stdin");
13
+ const maxBytes = Number(options.maxBytes);
14
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
15
+ throw new Error("--max-bytes must be a non-negative safe integer");
16
+ const client = createRealtimeClient();
17
+ const sessions = new Set();
18
+ const pending = new Set();
19
+ let room;
20
+ let helper;
21
+ let control;
22
+ let exit;
23
+ let killTimer;
24
+ let stopped = false;
25
+ let failure;
26
+ const stop = () => {
27
+ if (stopped)
28
+ return;
29
+ stopped = true;
30
+ helper?.kill("SIGTERM");
31
+ if (helper)
32
+ killTimer = setTimeout(() => helper?.kill("SIGKILL"), 5_000);
33
+ };
34
+ const fail = (cause) => { failure ??= cause; stop(); };
35
+ const send = (value) => {
36
+ if (!control || control.destroyed || stopped)
37
+ return;
38
+ const line = `${JSON.stringify(value)}\n`;
39
+ if (Buffer.byteLength(line) > 256 * 1024 || control.writableLength > 1024 * 1024)
40
+ throw new Error("RTC control input is too slow or too large");
41
+ control.write(line);
42
+ };
43
+ let previousMembers = "";
44
+ const report = (value) => {
45
+ const output = options.output === "-" ? process.stderr : process.stdout;
46
+ if (output.writableLength > 1024 * 1024)
47
+ throw new Error("RTC output is too slow");
48
+ output.write(`${JSON.stringify(value)}\n`);
49
+ };
50
+ const release = async (sessionId) => {
51
+ if (!sessions.has(sessionId))
52
+ return;
53
+ await client.rooms.signalRtc(roomId, { method: "DELETE", path: `/${sessionId}` });
54
+ sessions.delete(sessionId);
55
+ };
56
+ const request = async (message) => {
57
+ try {
58
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(message.method))
59
+ throw new Error("Invalid RTC signaling method");
60
+ const opening = message.method === "POST" && message.path === "";
61
+ const body = await client.rooms.signalRtc(roomId, {
62
+ method: message.method,
63
+ path: message.path,
64
+ body: opening ? { ticket: "hosted", participantId: room?.participantId } : message.method === "GET" ? undefined : message.body,
65
+ });
66
+ if (opening && body.sessionId)
67
+ sessions.add(body.sessionId);
68
+ if (message.method === "GET" && room) {
69
+ const snapshot = await client.rooms.get(roomId);
70
+ send({ kind: "members", body: snapshot.members.map(({ participantId, publications }) => ({ participantId, publications })) });
71
+ previousMembers = "";
72
+ }
73
+ send({ kind: "response", id: message.id, body });
74
+ }
75
+ catch (cause) {
76
+ send({ kind: "response", id: message.id, error: cause instanceof Error ? cause.message : String(cause), code: cause instanceof HttpError ? cause.code : "RTC_FAILED" });
77
+ }
78
+ };
79
+ const track = (task) => {
80
+ pending.add(task);
81
+ void task.catch(fail).finally(() => pending.delete(task));
82
+ };
83
+ process.once("SIGINT", stop);
84
+ process.once("SIGTERM", stop);
85
+ process.stdout.on("error", fail);
86
+ process.stderr.on("error", fail);
87
+ try {
88
+ const binary = await ensureRtcBinary(options.helper);
89
+ if (stopped)
90
+ return;
91
+ room = await client.rooms.connect(roomId);
92
+ if (stopped)
93
+ return;
94
+ room.onStateChange((state) => {
95
+ try {
96
+ report({ kind: "room", state });
97
+ }
98
+ catch (cause) {
99
+ fail(cause);
100
+ }
101
+ if (state === "closed" || state === "expired")
102
+ stop();
103
+ });
104
+ helper = spawn(binary, [], { stdio: ["inherit", "inherit", "inherit", "pipe", "pipe"] });
105
+ control = helper.stdio[3];
106
+ const events = helper.stdio[4];
107
+ control.on("error", fail);
108
+ exit = new Promise((resolve, reject) => {
109
+ helper?.once("error", reject);
110
+ helper?.once("close", (code, signal) => {
111
+ if (killTimer)
112
+ clearTimeout(killTimer);
113
+ if (code === 0 || stopped && signal === "SIGTERM")
114
+ resolve();
115
+ else
116
+ reject(new Error(`RTC helper exited with ${signal ?? code}`));
117
+ });
118
+ });
119
+ void exit.catch(fail);
120
+ send({ protocol: 1, ...options, maxBytes, participantId: room.participantId });
121
+ const updateMembers = () => {
122
+ const body = room?.members.map(({ participantId, publications }) => ({ participantId, publications }));
123
+ const next = JSON.stringify(body);
124
+ if (next === previousMembers)
125
+ return;
126
+ send({ kind: "members", body });
127
+ previousMembers = next;
128
+ };
129
+ room.onMembersChanged(() => { try {
130
+ updateMembers();
131
+ }
132
+ catch (cause) {
133
+ fail(cause);
134
+ } });
135
+ updateMembers();
136
+ report({ kind: "connected", roomId, participantId: room.participantId });
137
+ for await (const line of readRoomLines(events, 256 * 1024)) {
138
+ const message = JSON.parse(line);
139
+ if (message.kind === "request") {
140
+ if (pending.size >= 16)
141
+ throw new Error("Too many pending RTC requests");
142
+ track(request(message));
143
+ }
144
+ else if (message.kind === "release") {
145
+ await release(message.sessionId);
146
+ }
147
+ else
148
+ report(message);
149
+ }
150
+ await exit;
151
+ }
152
+ finally {
153
+ stop();
154
+ await exit?.catch((cause) => { failure ??= cause; });
155
+ if (killTimer)
156
+ clearTimeout(killTimer);
157
+ await Promise.allSettled(pending);
158
+ const results = await Promise.allSettled([...sessions].map(release));
159
+ for (const result of results)
160
+ if (result.status === "rejected")
161
+ failure ??= result.reason;
162
+ await room?.disconnect().catch((cause) => { failure ??= cause; });
163
+ await client.disconnect().catch((cause) => { failure ??= cause; });
164
+ process.removeListener("SIGINT", stop);
165
+ process.removeListener("SIGTERM", stop);
166
+ process.stdout.removeListener("error", fail);
167
+ process.stderr.removeListener("error", fail);
168
+ }
169
+ if (failure)
170
+ throw failure;
171
+ }
@@ -136,6 +136,51 @@ An Agent waits for the room in a background command and wakes when it returns:
136
136
  printView(view);
137
137
  console.log(`\nWait for events: cohub rooms events ${view.room.id} --after ${view.sequence} --wait 10m`);
138
138
  }));
139
+ rooms
140
+ .command("connect <room>")
141
+ .description("Stream room events and members over realtime; disconnecting keeps your seat")
142
+ .option("--after <sequence>", "Replay retained events after this sequence")
143
+ .option("--input", "Accept NDJSON publish/send/presence commands on stdin")
144
+ .option("--json", "Output newline-delimited JSON")
145
+ .action(async (roomId, opts) => {
146
+ try {
147
+ const { connectToRoom } = await import("./rooms-connect.js");
148
+ await connectToRoom(roomId, { ...opts, json: jsonRequested(opts) });
149
+ }
150
+ catch (cause) {
151
+ handleHttp(cause);
152
+ }
153
+ });
154
+ rooms
155
+ .command("rtc <room>")
156
+ .description("Stream RTC media and data through the Go/Pion helper")
157
+ .option("--helper <path>", "Use a local cohub-rtc binary")
158
+ .option("--channel <name>", "Open a JSON/binary RTC data channel")
159
+ .option("--delivery <policy>", "Data channel delivery: reliable or latest", "reliable")
160
+ .option("--data-input <path>", "NDJSON channel input file or - for stdin")
161
+ .option("--audio <path>", "Audio input file or - for stdin")
162
+ .option("--audio-format <format>", "Audio input: pcm or opus (Ogg)", "pcm")
163
+ .option("--rate <hz>", "Audio sample rate", "48000")
164
+ .option("--channels <count>", "Audio channels", "1")
165
+ .option("--video <path>", "Video input file or - for stdin")
166
+ .option("--video-format <format>", "Video input: i420 or vp8 (IVF)", "i420")
167
+ .option("--size <widthxheight>", "Video frame dimensions")
168
+ .option("--fps <rate>", "Video frame rate", "30")
169
+ .option("--output <directory|->", "Receive framed media into unique .rtc files or binary stdout")
170
+ .option("--receive-format <format>", "Receive rtp packets or raw decoded frames", "rtp")
171
+ .option("--receive-size <widthxheight>", "Decoded I420 output dimensions", "640x360")
172
+ .option("--max-bytes <bytes>", "Stop after this many received bytes; 0 is unlimited", "1073741824")
173
+ .option("--participant <id>", "Only receive this participant's tracks")
174
+ .option("--json", "Output newline-delimited JSON")
175
+ .action(async (roomId, opts) => {
176
+ try {
177
+ const { runRoomRtc } = await import("./rooms-rtc.js");
178
+ await runRoomRtc(roomId, opts);
179
+ }
180
+ catch (cause) {
181
+ handleHttp(cause);
182
+ }
183
+ });
139
184
  rooms
140
185
  .command("get <room>")
141
186
  .description("Show the room, its members and your seat")
@@ -1,4 +1,4 @@
1
- export declare const SANDBOXD_VERSION = "v2.61.1";
1
+ export declare const SANDBOXD_VERSION = "v2.67.0";
2
2
  export declare class SandboxdDownloadError extends Error {
3
3
  name: string;
4
4
  }
@@ -7,7 +7,7 @@ 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.61.1";
10
+ export const SANDBOXD_VERSION = "v2.67.0";
11
11
  const BINARY_NAME = "cohub-sandboxd";
12
12
  // Public CDN prefix hosting the release archives (the repo is private, so the
13
13
  // GitHub Release assets are not publicly downloadable). Overridable for staging
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "10.2.0",
3
+ "version": "10.3.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": "11.2.0",
21
+ "@neta-art/cohub": "11.3.0",
22
22
  "@neta-art/generation": "^0.1.35",
23
23
  "commander": "^15.0.0",
24
24
  "sharp": "^0.35.4",
@@ -42,6 +42,7 @@
42
42
  "scripts": {
43
43
  "build": "tsc -p tsconfig.build.json && tsdown",
44
44
  "verify:sandboxd": "node --import tsx scripts/verify-sandboxd-release.ts",
45
+ "verify:rtc": "node --import tsx scripts/verify-rtc-release.ts",
45
46
  "verify:board-kernel": "node --import tsx scripts/verify-board-kernel.ts",
46
47
  "test": "node ../../scripts/test/run.mjs 'tests/**/*.test.ts'",
47
48
  "test:runtime": "node --import tsx --test tests/runtime-*.integration.ts",