cozyclay 1.10.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/bin/agent/agent-routes.mjs +82 -72
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/codex-auth.mjs +2 -1
  10. package/bin/cozyclay.mjs +38 -11
  11. package/bin/live/cli.mjs +22 -3
  12. package/bin/mcp-runtime.mjs +45 -20
  13. package/bin/telemetry-state.mjs +5 -2
  14. package/bin/update-check.mjs +15 -0
  15. package/dist/app/index.html +6 -6
  16. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  17. package/dist/assets/app-BxBLC6GT.js +4876 -0
  18. package/dist/assets/app-CJdxvnrl.css +1 -0
  19. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  20. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  21. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  22. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  23. package/dist/assets/shot-prompt-hN0j_O-p.js +17 -0
  24. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  25. package/dist/assets/workflow-B5XZwVfe.js +179 -0
  26. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  29. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  30. package/dist/fonts/README.md +13 -9
  31. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  33. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  35. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  36. package/dist/index.html +7 -7
  37. package/dist/privacy/index.html +13 -8
  38. package/dist/sitemap.xml +2 -2
  39. package/dist/workflow/index.html +5 -5
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +219 -376
  44. package/package.json +2 -1
  45. package/src/App.jsx +1275 -9086
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/fix-collisions.js +10 -66
  51. package/src/ardy/ik-drag.js +101 -0
  52. package/src/ardy/ik-key-json.js +43 -0
  53. package/src/ardy/ik.js +97 -67
  54. package/src/ardy/physics-panel.css +2 -0
  55. package/src/ardy/physics-panel.jsx +17 -5
  56. package/src/ardy/platform-fit-panel.css +64 -0
  57. package/src/ardy/platform-fit-panel.jsx +40 -0
  58. package/src/ardy/platform-fit.js +326 -0
  59. package/src/ardy/playback.js +166 -20
  60. package/src/ardy/range-pin.js +299 -0
  61. package/src/ardy/timeline-coordinates.js +26 -0
  62. package/src/ardy/timeline.css +1214 -0
  63. package/src/ardy/timeline.jsx +152 -41
  64. package/src/ardy/waypoints.js +3 -3
  65. package/src/asset-pane.css +462 -0
  66. package/src/asset-pane.jsx +461 -232
  67. package/src/camera-tutorial.jsx +38 -18
  68. package/src/command-bus.js +349 -0
  69. package/src/commands/ai.js +31 -0
  70. package/src/commands/cast.js +140 -0
  71. package/src/commands/elements/character.js +13 -0
  72. package/src/commands/elements/motion.js +14 -0
  73. package/src/commands/elements/object.js +22 -0
  74. package/src/commands/elements/scene.js +16 -0
  75. package/src/commands/elements/shot.js +9 -0
  76. package/src/commands/elements/stage.js +11 -0
  77. package/src/commands/elements.js +155 -0
  78. package/src/commands/export.js +20 -0
  79. package/src/commands/index.js +40 -0
  80. package/src/commands/motion.js +231 -0
  81. package/src/commands/objects.js +174 -0
  82. package/src/commands/project.js +53 -0
  83. package/src/commands/scene.js +81 -0
  84. package/src/commands/shared.js +19 -0
  85. package/src/commands/shot.js +103 -0
  86. package/src/commands/stage.js +17 -0
  87. package/src/commands/view.js +42 -0
  88. package/src/document-store.js +144 -0
  89. package/src/domains/cast.js +1007 -0
  90. package/src/domains/motion.js +3219 -0
  91. package/src/domains/objects.js +932 -0
  92. package/src/domains/scenes.js +823 -0
  93. package/src/domains/shots.js +404 -0
  94. package/src/domains/stage.js +93 -0
  95. package/src/dualview.jsx +113 -57
  96. package/src/facing-marks.js +3 -0
  97. package/src/first-success-guide.jsx +14 -14
  98. package/src/grid-view.js +18 -5
  99. package/src/hierarchy-panel.css +386 -0
  100. package/src/hierarchy-panel.jsx +88 -19
  101. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  102. package/src/i2v-motion-studio.jsx +180 -0
  103. package/src/ik-camera.js +3 -0
  104. package/src/main.jsx +6 -0
  105. package/src/motion/generation.js +105 -0
  106. package/src/motion-readiness-ui.jsx +4 -4
  107. package/src/motion-readiness.js +11 -0
  108. package/src/motion-trail.js +366 -9
  109. package/src/object-gizmo.jsx +7 -1
  110. package/src/panels/CameraPanel.jsx +60 -0
  111. package/src/panels/CharacterTransformPanel.jsx +48 -0
  112. package/src/panels/EnvironmentPanel.jsx +40 -0
  113. package/src/panels/Foldout.jsx +32 -0
  114. package/src/panels/LightPanel.jsx +24 -0
  115. package/src/panels/ObjectTransformPanel.jsx +554 -0
  116. package/src/panels/PosePanel.jsx +97 -0
  117. package/src/panels/ProjectPanel.jsx +33 -0
  118. package/src/panels/PromptBlocksPanel.jsx +442 -0
  119. package/src/panels/PropsPanel.jsx +70 -0
  120. package/src/panels/ReferenceImageField.jsx +91 -0
  121. package/src/panels/RigControlPanel.jsx +78 -0
  122. package/src/panels/RigPanel.jsx +37 -0
  123. package/src/panels/SubjectBox.jsx +47 -0
  124. package/src/panels/SubjectsPanel.jsx +39 -0
  125. package/src/panels/VideoCapturePanel.jsx +180 -0
  126. package/src/panels/details.css +1171 -0
  127. package/src/panels/motion.css +197 -0
  128. package/src/panels/pose.css +39 -0
  129. package/src/planview.jsx +60 -31
  130. package/src/posestudio.jsx +59 -4
  131. package/src/project-browser.css +1073 -0
  132. package/src/project-browser.jsx +288 -123
  133. package/src/range-pin-object-transform.js +19 -0
  134. package/src/range-pin-panel.css +518 -0
  135. package/src/range-pin-panel.jsx +344 -0
  136. package/src/result-modal.jsx +5 -5
  137. package/src/room.jsx +79 -51
  138. package/src/scenes.js +9 -8
  139. package/src/semantic-edit.js +2 -0
  140. package/src/settings-menu.jsx +39 -105
  141. package/src/shell/BottomDock.jsx +450 -0
  142. package/src/shell/DetailsSlot.jsx +513 -0
  143. package/src/shell/LibrarySlot.jsx +86 -0
  144. package/src/shell/MenuBar.jsx +601 -0
  145. package/src/shell/OutlinerSlot.jsx +50 -0
  146. package/src/shell/PreferencesDialog.jsx +538 -0
  147. package/src/shell/PreferencesSlot.jsx +40 -0
  148. package/src/shell/StatusBar.jsx +125 -0
  149. package/src/shell/StudioShell.jsx +92 -0
  150. package/src/shell/TopBar.jsx +129 -0
  151. package/src/shell/ViewportToolbar.jsx +656 -0
  152. package/src/shell/agent-glass.css +296 -0
  153. package/src/shell/dock.css +93 -0
  154. package/src/shell/glass-regions.css +985 -0
  155. package/src/shell/glass.css +687 -0
  156. package/src/shell/log-store.js +52 -0
  157. package/src/shell/mode.css +9 -0
  158. package/src/shell/preferences.css +651 -0
  159. package/src/shell/shell.css +282 -0
  160. package/src/shell/studio-shell-context.js +19 -0
  161. package/src/shell/topbar.css +435 -0
  162. package/src/shell/viewport.css +451 -0
  163. package/src/store/authored-intent.js +22 -0
  164. package/src/store/runtime-adapters.js +92 -0
  165. package/src/store/scene-stage.js +21 -0
  166. package/src/store/use-document-store.js +14 -0
  167. package/src/studio-actions.js +210 -0
  168. package/src/studio-agent-commands.js +38 -268
  169. package/src/studio-agent-context.js +38 -9
  170. package/src/studio-agent-motion.js +122 -378
  171. package/src/studio-agent-protocol.js +66 -30
  172. package/src/studio-app-binding.js +379 -0
  173. package/src/studio-contact-sheet.js +73 -0
  174. package/src/studio-elements.js +58 -51
  175. package/src/styles/themes.css +200 -0
  176. package/src/styles/tokens.css +69 -0
  177. package/src/styles.css +456 -1748
  178. package/src/theme.js +44 -0
  179. package/src/timeline-extent.js +16 -0
  180. package/src/timeline-fps.js +13 -0
  181. package/src/trail-key-conflicts.js +39 -0
  182. package/src/trail-pick.js +59 -0
  183. package/src/ui.jsx +8 -8
  184. package/src/use-case-question.jsx +66 -0
  185. package/src/workflow/AgentPanel.jsx +86 -71
  186. package/src/workflow/agent-client.js +12 -2
  187. package/src/workflow/agent-panel.css +34 -32
  188. package/src/workflow/cozy-scene-node.css +2 -0
  189. package/src/workflow/workflow.css +2 -0
  190. package/tools/ardy/bridge.mjs +164 -146
  191. package/tools/ardy/visual-qa.mjs +5 -5
  192. package/tools/bench/EXP3.md +61 -0
  193. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  194. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  195. package/tools/bench/cclay_bench_runner.py +42 -0
  196. package/tools/bench/cube-contact.mjs +162 -0
  197. package/tools/bench/exp3.mjs +152 -0
  198. package/tools/bench/extract-bench-lib.mjs +216 -0
  199. package/tools/bench/extract-bench.mjs +302 -0
  200. package/tools/bench/fal-generate.mjs +88 -0
  201. package/tools/bench/fit/README.md +189 -0
  202. package/tools/bench/fit/camera.mjs +59 -0
  203. package/tools/bench/fit/contact.mjs +419 -0
  204. package/tools/bench/fit/footlock.mjs +234 -0
  205. package/tools/bench/fit/motion.mjs +85 -0
  206. package/tools/bench/fit/pin.mjs +27 -0
  207. package/tools/bench/fit/remote.mjs +105 -0
  208. package/tools/bench/fit-bench.mjs +126 -0
  209. package/tools/bench/fit-sanity.mjs +45 -0
  210. package/tools/bench/metrics.mjs +313 -0
  211. package/tools/bench/obs/depth.mjs +258 -0
  212. package/tools/bench/obs/extrinsics.mjs +175 -0
  213. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  214. package/tools/bench/obs/ground.mjs +294 -0
  215. package/tools/bench/obs/heading.mjs +151 -0
  216. package/tools/bench/obs/ladder.mjs +555 -0
  217. package/tools/bench/obs/mannequin-betas.json +124 -0
  218. package/tools/bench/obs/remote.mjs +83 -0
  219. package/tools/bench/obs/rest_joints.py +52 -0
  220. package/tools/bench/obs/ybot-targets.mjs +49 -0
  221. package/tools/bench/obs-bench.mjs +520 -0
  222. package/tools/bench/score.mjs +343 -0
  223. package/tools/bench/summarize.mjs +86 -0
  224. package/tools/dev/pages/privacy.html +13 -8
  225. package/tools/gt-render/browser.mjs +159 -0
  226. package/tools/gt-render/camera-math.mjs +276 -0
  227. package/tools/gt-render/page.mjs +267 -0
  228. package/tools/gt-render/render.mjs +514 -0
  229. package/tools/gt-render/scene-box.mjs +31 -0
  230. package/tools/gt-render/take-transform.mjs +87 -0
  231. package/tools/qa/css-rule-usage.mjs +283 -0
  232. package/tools/qa/studio-control-count.mjs +70 -32
  233. package/tools/run-tests.mjs +60 -3
  234. package/tools/track/DESIGN.md +513 -0
  235. package/tools/track/backfill-provenance.mjs +116 -0
  236. package/tools/track/budget.mjs +8 -0
  237. package/tools/track/check-rig.mjs +209 -0
  238. package/tools/track/diagnostics.schema.json +60 -0
  239. package/tools/track/export-rig.mjs +256 -0
  240. package/tools/track/fallback.mjs +7 -0
  241. package/tools/track/fk-parity-fixture.mjs +161 -0
  242. package/tools/track/gate.mjs +349 -0
  243. package/tools/track/masks.mjs +318 -0
  244. package/tools/track/metrics.mjs +201 -0
  245. package/tools/track/publish-obs.mjs +108 -0
  246. package/tools/track/py/check_env.py +61 -0
  247. package/tools/track/py/eval_lr.py +229 -0
  248. package/tools/track/py/lr_viterbi.py +294 -0
  249. package/tools/track/py/masks.py +405 -0
  250. package/tools/track/py/objective.py +382 -0
  251. package/tools/track/py/rig.py +508 -0
  252. package/tools/track/py/scene.py +326 -0
  253. package/tools/track/py/test_joint_indices.py +123 -0
  254. package/tools/track/py/test_lr_viterbi.py +195 -0
  255. package/tools/track/py/test_masks.py +229 -0
  256. package/tools/track/py/test_rig.py +288 -0
  257. package/tools/track/py/test_scene.py +354 -0
  258. package/tools/track/py/test_track.py +328 -0
  259. package/tools/track/py/track.py +411 -0
  260. package/tools/track/remote.mjs +140 -0
  261. package/tools/track/rig-dump.mjs +227 -0
  262. package/tools/track/run-box-tests.mjs +14 -0
  263. package/tools/track/run-box.mjs +168 -0
  264. package/tools/track/setup-box.sh +59 -0
  265. package/tools/track/study-2d.mjs +728 -0
  266. package/dist/assets/analytics-B1hnH66c.js +0 -1
  267. package/dist/assets/app-Cu1cexZR.css +0 -1
  268. package/dist/assets/app-Dxdh8kds.js +0 -4861
  269. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  270. package/dist/assets/shot-prompt-HVJRP4Gr.js +0 -17
  271. package/dist/assets/workflow-CJd-PDtV.js +0 -179
  272. package/dist/assets/workflow-DsudxKHF.css +0 -1
  273. package/src/fal-motion-studio.jsx +0 -180
  274. package/src/scene-history.js +0 -129
  275. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
@@ -28,10 +28,10 @@ import { createHash, randomUUID } from "node:crypto";
28
28
 
29
29
  import { z } from "zod";
30
30
 
31
- import { LiveMutationUncertainError } from "./live-hub.mjs";
31
+ import { MAX_COMMAND_TIMEOUT_MS } from "./live-hub.mjs";
32
32
  import { readMeshFromPath } from "./mesh-file.mjs";
33
- import { readMotionStream } from "../bin/agent/motion-runtime.mjs";
34
- import { motionPreflightReason, startMotionRequest } from "../src/analytics.js";
33
+ import { DEFAULT_POSE } from "../src/poses.js";
34
+ import { generationArgs } from '../src/motion/generation.js';
35
35
  import { BLOCK_MAX_SECONDS, PROMPT_GUIDE, normalizePhases, splitLongBeat, tileClipFrames } from "./ardy-prompts.mjs";
36
36
 
37
37
  import {
@@ -99,6 +99,7 @@ const liveWorkspaceTools = new Set([
99
99
  "set_camera", "frame_shot", "add_character", "place_character", "remove_character",
100
100
  "focus_character", "place_object", "import_mesh", "group_objects", "set_prompt_blocks", "generate_motion", "update_object",
101
101
  "remove_object", "apply_batch", "add_scene", "switch_scene", "open_project", "capture_frame", "load_motion",
102
+ "studio_commands", "studio_run",
102
103
  ]);
103
104
  const MAX_CAPTURE_BYTES = 1_000_000;
104
105
  const CAPTURE_ARTIFACT_TTL_MS = 10 * 60_000;
@@ -166,8 +167,41 @@ const TOOL_ANNOTATIONS = Object.freeze({
166
167
  switch_scene: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
167
168
  open_project: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
168
169
  save_project: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
170
+ studio_commands: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
171
+ // Whatever the editor registers: some commands delete, export or spend a
172
+ // paid generation, and those answer CONFIRMATION_REQUIRED instead of running.
173
+ studio_run: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
169
174
  });
170
175
 
176
+ /** The document identity a Studio command is admitted in. */
177
+ const STUDIO_IDENTITY_KEYS = ["workspaceId", "documentEpoch", "sceneId", "sceneEpoch"];
178
+
179
+ /** One registered editor command through the editor's own bus, admitted at
180
+ * the open document and its current revision like the agent's run_action. Its
181
+ * declaration (read from the editor, never from this server) sets the hub
182
+ * deadline unless the caller gives one. */
183
+ const executeStudioCommand = async ({ action, args, expectedRevision, commandId, timeoutMs, confirmationToken, inspected }) => {
184
+ const workspaceHandle = liveWorkspace.getStore();
185
+ inspected ??= await liveHub.command("inspect_studio", { scope: "actions", ids: [action] }, workspaceHandle);
186
+ const context = inspected?.context;
187
+ if (!context?.host || !Number.isSafeInteger(context.revision?.scene)) throw new Error("The editor did not return a Studio context to admit this command against.");
188
+ const declared = inspected.actions?.find((row) => row.id === action);
189
+ const receipt = await liveHub.command("run_action", {
190
+ name: "run_action",
191
+ args: { action, args: typeof args === "function" ? await args(context) : args ?? {}, ...(confirmationToken ? { confirmationToken } : {}) },
192
+ commandId: commandId ?? randomUUID(),
193
+ host: Object.fromEntries(STUDIO_IDENTITY_KEYS.map((key) => [key, context.host[key]])),
194
+ expectedRevision: expectedRevision ?? context.revision.scene,
195
+ }, workspaceHandle, { timeoutMs: timeoutMs ?? (declared?.timeoutMs === undefined ? undefined : Math.min(MAX_COMMAND_TIMEOUT_MS, declared.timeoutMs + (declared.generation ? 5000 : 0))) });
196
+ return receipt;
197
+ };
198
+ // A refusal is the editor's receipt: its code and recovery are the answer.
199
+ const studioResult = (...receipts) => ({
200
+ content: receipts.map(receipt => ({ type: "text", text: JSON.stringify(receipt) })),
201
+ ...(receipts.some(receipt => receipt.ok === false) ? { isError: true } : {}),
202
+ });
203
+ const runStudioCommand = async options => studioResult(await executeStudioCommand(options));
204
+
171
205
  const scene = () => activeScene(state.doc.scenes, state.doc.activeSceneId);
172
206
  const stage = () => scene().stage;
173
207
 
@@ -189,6 +223,13 @@ const findCharacter = (ref) => {
189
223
  return null;
190
224
  };
191
225
 
226
+ // Resolve legacy letters and slots from the complete live cast inside the
227
+ // inspected admission. A missing id is refused by the command, not hidden here.
228
+ const liveCharacterId = async ref => {
229
+ await refreshLiveDescription();
230
+ return findCharacter(ref)?.id ?? ref;
231
+ };
232
+
192
233
  /** The studio labels the cast A, B, C… by position. */
193
234
  const letterFor = (character) => String.fromCharCode(65 + cast().indexOf(character));
194
235
 
@@ -245,31 +286,6 @@ const framing = () => {
245
286
 
246
287
  const currentShot = () => deriveShot(state.camera, subject(), fov(), undefined, filmback());
247
288
 
248
- const appliedLiveMutation = async (name, args) => {
249
- const workspaceHandle = liveWorkspace.getStore();
250
- const value = await liveHub.command(name, args, workspaceHandle);
251
- try {
252
- if (!await refreshLiveDescription(workspaceHandle)) throw new Error("Live editor disconnected before verification.");
253
- } catch (error) {
254
- throw new LiveMutationUncertainError(`Live editor accepted ${name}, but its state could not be verified: ${error.message} The mutation may have been applied. Do not retry it; describe the scene before choosing a recovery action.`);
255
- }
256
- return value;
257
- };
258
-
259
- const requireLiveSceneParity = (name, document, result) => {
260
- const expected = document.scenes.map(({ id, name: sceneName }) => ({ id, name: sceneName }));
261
- const received = Array.isArray(result?.scenes) ? result.scenes : [];
262
- const sameScenes = received.length === expected.length && received.every((scene, index) =>
263
- scene?.id === expected[index].id && scene?.name === expected[index].name,
264
- );
265
- if (sameScenes && result?.activeSceneId === document.activeSceneId) return;
266
- throw new LiveMutationUncertainError(
267
- `Live editor accepted ${name}, but did not confirm the complete scene list and active scene ` +
268
- `(expected ${JSON.stringify(expected)} active ${document.activeSceneId}; received ${JSON.stringify(received)} active ${result?.activeSceneId ?? "none"}). ` +
269
- "The mutation may have been applied. Do not retry it; describe the scene before choosing a recovery action.",
270
- );
271
- };
272
-
273
289
  const modelById = (id) =>
274
290
  [...VIDEO_MODELS, ...IMAGE_MODELS].find((m) => m.id === id) ?? null;
275
291
 
@@ -470,13 +486,24 @@ function shotReport() {
470
486
 
471
487
  /* --------------------------------- tools --------------------------------- */
472
488
 
489
+ const studioAliasTools = new Set([
490
+ "set_camera", "frame_shot", "place_object", "update_object", "remove_object",
491
+ "import_mesh", "group_objects", "add_scene", "switch_scene", "apply_batch", "open_project",
492
+ "add_character", "place_character", "remove_character", "set_prompt_blocks", "load_motion", "generate_motion",
493
+ ]);
494
+ const studioAdmissionSchema = {
495
+ expectedRevision: z.number().int().min(0).optional().describe("scene revision to admit against; defaults to the inspected revision"),
496
+ commandId: z.string().min(1).max(120).optional().describe("idempotency key; reuse with the original expectedRevision to replay its receipt"),
497
+ timeoutMs: z.number().int().min(1).max(MAX_COMMAND_TIMEOUT_MS).optional().describe("hub deadline; defaults to the editor command declaration"),
498
+ };
499
+
473
500
  /** One tool, as the registry hands it out: everything server.mjs needs to
474
501
  * register it, and everything a direct caller needs to run it. */
475
502
  const tool = (name, config, handler) => ({
476
503
  name,
477
504
  title: config.title,
478
505
  description: config.description,
479
- inputSchema: config.inputSchema,
506
+ inputSchema: studioAliasTools.has(name) ? { ...config.inputSchema, ...studioAdmissionSchema } : config.inputSchema,
480
507
  annotations: TOOL_ANNOTATIONS[name],
481
508
  live: liveWorkspaceTools.has(name),
482
509
  handler,
@@ -489,14 +516,9 @@ const tool = (name, config, handler) => ({
489
516
  * @param {Promise<string>} [deps.projectRootPromise] the resolved directory
490
517
  * `open_project`/`save_project` confine themselves to; the owner resolves it
491
518
  * because doing so chdirs the process.
492
- * @param {object} [deps.motionJobs] the MotionJobRegistry `generate_motion`
493
- * records work in.
494
- * @param {(job: object) => Promise<void>} [deps.publishMotionJob] delivers a
495
- * finished job to its workspace; the owner holds it because the live hub's
496
- * reconnect and cancel paths publish jobs too.
497
519
  * @returns {Array<{name: string, title: string, description: string, inputSchema: object, annotations: object, live: boolean, handler: Function}>}
498
520
  */
499
- export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMotionJob } = {}) => {
521
+ export const createToolHandlers = ({ projectRootPromise } = {}) => {
500
522
  const resolveProjectPath = async (path, { existing }) => {
501
523
  if (!path.endsWith(".cclayproject")) throw new Error("Project path must end in .cclayproject.");
502
524
  const root = await projectRootPromise;
@@ -675,17 +697,19 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
675
697
  look_at_z: z.number().optional().describe("world z of an explicit aim point; give all three to aim the lens"),
676
698
  },
677
699
  },
678
- async ({ x, y, z: zPos, focal_mm, look_at_x, look_at_y, look_at_z }) => {
700
+ async ({ x, y, z: zPos, focal_mm, look_at_x, look_at_y, look_at_z, ...admission }) => {
679
701
  if (liveHub?.connected) {
680
702
  try {
681
- // The aim is only forwarded when the caller gave a whole point: a
682
- // partial target has no direction, and an omitted one deliberately
683
- // leaves the live editor's orientation exactly where it was.
684
- const aim = [look_at_x, look_at_y, look_at_z].every((value) => value !== undefined)
685
- ? { lookAtX: look_at_x, lookAtY: look_at_y, lookAtZ: look_at_z }
686
- : {};
687
- await appliedLiveMutation("set_camera", { x, y, z: zPos, focalMm: focal_mm, ...aim });
688
- return text(`Camera set.\n\n${shotReport()}`);
703
+ return await runStudioCommand({ ...admission, action: "shot.frame", args: context => {
704
+ const camera = context.camera;
705
+ const position = { x: x ?? camera.position.x, y: y ?? camera.position.y, z: zPos ?? camera.position.z };
706
+ // Preserve direction when no complete explicit aim point was given.
707
+ const lookAt = [look_at_x, look_at_y, look_at_z].every(value => value !== undefined)
708
+ ? { x: look_at_x, y: look_at_y, z: look_at_z }
709
+ : Object.fromEntries(["x", "y", "z"].map(axis => [axis, position[axis] + camera.lookAt[axis] - camera.position[axis]]));
710
+ return { subjectIds: [state.focusLocked ? state.focus : context.activeCharacterId],
711
+ framing: { exact: { position, lookAt, focalMm: focal_mm ?? camera.focalMm } } };
712
+ } });
689
713
  } catch (error) {
690
714
  return liveError(error);
691
715
  }
@@ -730,9 +754,11 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
730
754
  focal_mm: z.number().min(8).max(300).default(35).describe("lens to frame with"),
731
755
  },
732
756
  },
733
- async ({ size, view, level, side, focal_mm }) => {
757
+ async ({ size, view, level, side, focal_mm, ...admission }) => {
758
+ let inspected;
734
759
  if (liveHub?.connected) {
735
760
  try {
761
+ inspected = await liveHub.command("inspect_studio", { scope: "actions", ids: ["shot.frame"] }, liveWorkspace.getStore());
736
762
  await refreshLiveDescription();
737
763
  } catch (error) {
738
764
  return liveError(error);
@@ -805,19 +831,13 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
805
831
  };
806
832
  if (liveHub?.connected) {
807
833
  try {
808
- // Placing the lens is only half the shot. deriveShot and captureFraming
809
- // both measure as if the lens points at the framing pivot (see
810
- // aimAtSubject), which the in-memory path gets for free because it has
811
- // no orientation at all. A live editor has one and keeps it, so a
812
- // position-only move orbited every view except `front` off the subject
813
- // while the slate still read "98% of frame height". Send the same pivot
814
- // the vocabulary is measured against, with the position.
815
- await appliedLiveMutation("set_camera", {
816
- ...nextCamera,
817
- lookAtX: s.x,
818
- lookAtY: FRAMING_PIVOT_Y,
819
- lookAtZ: s.z,
820
- });
834
+ return await runStudioCommand({ ...admission, action: "shot.frame", inspected, args: {
835
+ subjectIds: [(findCharacter(state.focus) ?? cast()[0]).id],
836
+ framing: { exact: {
837
+ position: { x: nextCamera.x, y: nextCamera.y, z: nextCamera.z },
838
+ lookAt: { x: s.x, y: FRAMING_PIVOT_Y, z: s.z }, focalMm: lensMm,
839
+ } },
840
+ } });
821
841
  } catch (error) {
822
842
  return liveError(error);
823
843
  }
@@ -852,13 +872,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
852
872
  .describe("which mannequin to use"),
853
873
  },
854
874
  },
855
- async ({ subject: desc, x, z: zPos, facing, model }) => {
875
+ async ({ subject: desc, x, z: zPos, facing, model, ...admission }) => {
856
876
  if (liveHub?.connected) {
857
877
  try {
858
- const result = await appliedLiveMutation("add_character", { subject: desc, x, z: zPos, rot: facing, model });
859
- const added = stage().characters.find((character) => character.id === result?.id);
860
- if (added && model !== undefined) added.model = model;
861
- return text(`Added ${result?.id ?? "character"}.\n\n${sceneReport()}`);
878
+ return await runStudioCommand({ ...admission, action: "character.add", args: {
879
+ character: { subject: desc, x, z: zPos, rot: facing, model, pose: DEFAULT_POSE },
880
+ } });
862
881
  } catch (error) {
863
882
  return liveError(error);
864
883
  }
@@ -901,11 +920,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
901
920
  hidden: z.boolean().optional().describe("hide without removing from the cast"),
902
921
  },
903
922
  },
904
- async ({ character, x, z: zPos, y, facing, subject: desc, hidden }) => {
923
+ async ({ character, x, z: zPos, y, facing, subject: desc, hidden, ...admission }) => {
905
924
  if (liveHub?.connected) {
906
925
  try {
907
- await appliedLiveMutation("update_character", { ref: character, x, y, z: zPos, rot: facing, subject: desc, hidden });
908
- return text(`Character updated.\n\n${sceneReport()}`);
926
+ return await runStudioCommand({ ...admission, action: "character.update", args: async () => ({
927
+ characterId: await liveCharacterId(character), patch: { x, y, z: zPos, rot: facing, subject: desc, hidden },
928
+ }) });
909
929
  } catch (error) {
910
930
  return liveError(error);
911
931
  }
@@ -931,11 +951,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
931
951
  character: z.string().describe('which character — letter, slot number or id'),
932
952
  },
933
953
  },
934
- async ({ character }) => {
954
+ async ({ character, ...admission }) => {
935
955
  if (liveHub?.connected) {
936
956
  try {
937
- await appliedLiveMutation("remove_character", { ref: character });
938
- return text(`Character removed.\n\n${sceneReport()}`);
957
+ return await runStudioCommand({ ...admission, action: "character.remove", args: async () => ({
958
+ characterId: await liveCharacterId(character),
959
+ }) });
939
960
  } catch (error) {
940
961
  return liveError(error);
941
962
  }
@@ -1001,11 +1022,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1001
1022
  .describe("object id to attach to — the parent then carries this object when it moves"),
1002
1023
  },
1003
1024
  },
1004
- async ({ kind, x, z: zPos, y, facing, name, parent }) => {
1025
+ async ({ kind, x, z: zPos, y, facing, name, parent, ...admission }) => {
1005
1026
  if (liveHub?.connected) {
1006
1027
  try {
1007
- const result = await appliedLiveMutation("place_object", { kind, x, z: zPos, y, rot: facing, name, parent });
1008
- return text(`Placed object as ${result?.id ?? "unknown"}.\n\n${sceneReport()}`);
1028
+ return await runStudioCommand({ ...admission, action: "object.add", args: { kind, placement: { x, z: zPos, y, rot: facing }, name, parent } });
1009
1029
  } catch (error) {
1010
1030
  return liveError(error);
1011
1031
  }
@@ -1053,7 +1073,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1053
1073
  height: z.number().positive().optional().describe("standing height in metres; omitted uses the fitted size"),
1054
1074
  },
1055
1075
  },
1056
- async ({ path, clay, name, x, z: zPos, y, facing, height }) => {
1076
+ async ({ path, clay, name, x, z: zPos, y, facing, height, ...admission }) => {
1057
1077
  if (!liveHub?.connected) {
1058
1078
  return liveError(new Error(noLiveEditor("import_mesh requires a connected CozyClay editor.")));
1059
1079
  }
@@ -1066,7 +1086,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1066
1086
  const liveArgs = {
1067
1087
  name: typeof name === "string" && name.trim() ? name.trim() : mesh.name,
1068
1088
  mimeType: mesh.mimeType,
1069
- dataUrl: `data:${mesh.mimeType};base64,${Buffer.from(mesh.bytes).toString("base64")}`,
1089
+ source: `data:${mesh.mimeType};base64,${Buffer.from(mesh.bytes).toString("base64")}`,
1070
1090
  placeAs: "mesh",
1071
1091
  };
1072
1092
  if (clay === true) liveArgs.clay = true;
@@ -1078,10 +1098,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1078
1098
  if (facing !== undefined) liveArgs.rot = facing;
1079
1099
  if (height !== undefined) liveArgs.height = height;
1080
1100
  try {
1081
- const result = await appliedLiveMutation("import_asset", liveArgs);
1082
- return text(
1083
- `Placed ${liveArgs.name} as ${result?.objectId ?? "unknown"} (${result?.assetId ?? "unknown"}).\n\n${sceneReport()}`,
1084
- );
1101
+ return await runStudioCommand({ ...admission, action: "asset.import", args: liveArgs });
1085
1102
  } catch (error) {
1086
1103
  return liveError(error);
1087
1104
  }
@@ -1102,16 +1119,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1102
1119
  children: z.array(z.string()).min(1).describe("object ids to attach or detach"),
1103
1120
  },
1104
1121
  },
1105
- async ({ parent, children }) => {
1122
+ async ({ parent, children, ...admission }) => {
1106
1123
  if (liveHub?.connected) {
1107
1124
  try {
1108
- await appliedLiveMutation(parent === null ? "ungroup_objects" : "group_objects", { parent, children });
1109
- return text(
1110
- (parent === null
1111
- ? `Detached ${children.length} object(s).`
1112
- : `Grouped ${children.length} object(s) under ${parent} — move ${parent} and they follow.`) +
1113
- `\n\n${sceneReport()}`,
1114
- );
1125
+ return await runStudioCommand({ ...admission, action: parent === null ? "object.ungroup" : "object.group", args: parent === null ? { children } : { parent, children } });
1115
1126
  } catch (error) {
1116
1127
  return liveError(error);
1117
1128
  }
@@ -1136,7 +1147,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1136
1147
  description:
1137
1148
  "Write Prompt Blocks onto the timeline WITHOUT generating — the beats and their frame " +
1138
1149
  "ranges, so a schedule can be read and revised before any GPU time is spent. Hit " +
1139
- "'Generate all N blocks' in the studio, or call generate_motion, when it reads right.\n\n" +
1150
+ "the studio's top-bar Generate Motion button, or call generate_motion, when it reads right.\n\n" +
1140
1151
  PROMPT_GUIDE,
1141
1152
  inputSchema: {
1142
1153
  beats: z
@@ -1156,7 +1167,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1156
1167
  .describe("beats in order; each one becomes a contiguous block"),
1157
1168
  },
1158
1169
  },
1159
- async ({ beats }) => {
1170
+ async ({ beats, ...admission }) => {
1160
1171
  if (!liveHub?.connected) {
1161
1172
  return text("Prompt Blocks live on the studio timeline — open the editor and try again.");
1162
1173
  }
@@ -1164,12 +1175,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1164
1175
  // The timeline runs on a 24 fps production clock.
1165
1176
  const TIMELINE_FPS = 24;
1166
1177
  let cursor = 0;
1167
- let chained = 0;
1168
1178
  const blocks = [];
1169
1179
  for (const [i, textValue] of normalized.texts.entries()) {
1170
1180
  const whole = beats[Math.min(normalized.sources[i], beats.length - 1)].seconds ?? 3;
1171
1181
  const spans = splitLongBeat(whole);
1172
- if (spans.length > 1) chained += spans.length - 1;
1173
1182
  for (const span of spans) {
1174
1183
  const frames = Math.max(1, Math.round(span * TIMELINE_FPS));
1175
1184
  blocks.push({ startFrame: cursor, endFrame: cursor + frames, text: textValue });
@@ -1177,24 +1186,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1177
1186
  }
1178
1187
  }
1179
1188
  try {
1180
- await appliedLiveMutation("set_prompt_blocks", { blocks });
1189
+ return await runStudioCommand({ ...admission, action: "character.setPromptBlocks", args: context => ({
1190
+ characterId: context.activeCharacterId, blocks,
1191
+ }) });
1181
1192
  } catch (error) {
1182
1193
  return liveError(error);
1183
1194
  }
1184
- // Report the normalised text itself, not blocks[i]: a long beat becomes
1185
- // several blocks, so block indices run ahead of phase indices and quoting
1186
- // blocks[i] would attribute one beat's edits to another beat's wording.
1187
- const rewrites = normalized.notes
1188
- .map((notes, i) => (notes.length ? ` ${i + 1}. ${normalized.texts[i]} ← ${notes.join("; ")}` : null))
1189
- .filter(Boolean);
1190
- return text(
1191
- `${blocks.length} block(s) on the timeline (${(cursor / TIMELINE_FPS).toFixed(1)}s total):\n` +
1192
- blocks.map((b) => ` ${b.startFrame}-${b.endFrame}f ${b.text}`).join("\n") +
1193
- (chained > 0 ? `\n (${chained} block(s) chained to keep every block within ${BLOCK_MAX_SECONDS}s)` : "") +
1194
- (rewrites.length ? `\n\nRewritten for ARDY:\n${rewrites.join("\n")}` : "") +
1195
- (normalized.dropped > 0 ? `\n (${normalized.dropped} beat(s) past the 8-phase limit were dropped)` : "") +
1196
- "\n\nGenerate them from the studio's Prompt Blocks panel, or with generate_motion.",
1197
- );
1198
1195
  },
1199
1196
  ),
1200
1197
 
@@ -1216,23 +1213,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1216
1213
  if (!motionUrlPattern.test(args.url)) {
1217
1214
  throw new Error(`Unsupported motion url "${args.url}". Use /ardy/motions/<id> or /ardy/assembled/<name>.npz.`);
1218
1215
  }
1219
- const workspaceHandle = liveWorkspace.getStore() ?? liveHub.resolveWorkspace("load_motion", args.workspace_handle);
1220
- // Without an explicit target the editor installs onto ITS active character,
1221
- // which silently stacks multi-cast loads onto one rig — the generate path
1222
- // always names its target, so the load path must be able to as well.
1223
- let characterId;
1224
- if (args.character !== undefined) {
1225
- try {
1226
- await refreshLiveDescription();
1227
- } catch (error) {
1228
- return liveError(error);
1229
- }
1230
- const target = findCharacter(args.character);
1231
- if (!target) return text(`No character "${args.character}". ${castHint()}`);
1232
- characterId = target.id;
1233
- }
1234
- await liveHub.command("load_motion", { url: args.url, prompt: args.prompt ?? "", ...(characterId ? { characterId } : {}) }, workspaceHandle);
1235
- return text(`Motion installed from ${args.url}${characterId ? ` onto ${characterId}` : ""}.`);
1216
+ return runStudioCommand({ ...args, action: 'motion.replace', args: async context => ({
1217
+ characterId: args.character === undefined ? context.activeCharacterId : await liveCharacterId(args.character),
1218
+ url: args.url, prompt: args.prompt ?? '',
1219
+ }) });
1236
1220
  },
1237
1221
  ),
1238
1222
 
@@ -1241,9 +1225,8 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1241
1225
  {
1242
1226
  title: "Generate character motion (Kimodo)",
1243
1227
  description:
1244
- "Start a multi-phase Kimodo motion job for the connected live editor and return immediately with its " +
1245
- "task-shaped identity. Completion, failure, or cancellation arrives as the live socket's motion_job event; " +
1246
- "the MCP model does not poll task state. A completed event loads the active character's timeline.\n\n" +
1228
+ "Alias for motion.generate in the editor. Returns its bus receipt; started jobs carry jobId for studio_run job.await or job.cancel. " +
1229
+ "The installed take is undoable with studio_run edit.undo.\n\n" +
1247
1230
  PROMPT_GUIDE,
1248
1231
  inputSchema: {
1249
1232
  phases: z
@@ -1290,203 +1273,16 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1290
1273
  ),
1291
1274
  },
1292
1275
  },
1293
- async ({ phases: rawPhases, seconds, seed, motion_url, drop }) => {
1294
- const phases = rawPhases.map((p) => (typeof p === "string" ? p : p.text));
1295
- const phaseSeconds = rawPhases.map((p) => (typeof p === "string" ? null : p.seconds));
1296
- const timed = phaseSeconds.some((s) => s !== null);
1297
- const bridge = (process.env.COZYCLAY_BRIDGE_ORIGIN ?? process.env.COZYCLAY_BRIDGE ?? "http://127.0.0.1:5181").replace(/\/$/, "");
1298
-
1299
- // Every phase is rewritten into ARDY's own sentence shape before it is ever
1300
- // sent. One input beat stays one phase: the caller's phase list is the
1301
- // sequence, so a composite beat is theirs to split, not ours to re-cut.
1302
- const normalized = normalizePhases(phases);
1303
- // Keep notes beside the prompt they belong to: a blank beat normalises to
1304
- // "" and is dropped, which would otherwise slide every later note onto the
1305
- // wrong prompt in the rewrite report.
1306
- const kept = normalized.texts
1307
- .map((t, i) => ({ text: t, notes: normalized.notes[i], source: normalized.sources[i] }))
1308
- .filter((p) => p.text);
1309
- const prompts = kept.map((p) => p.text);
1310
- // One complete beat is a legitimate clip — ARDY's own examples are single
1311
- // prompts ("A person walks in a circle."), so a valid one-phase request must
1312
- // not fail merely for being one phase. Only an empty request is refused.
1313
- if (prompts.length === 0) return text("Give at least one motion beat.");
1314
-
1315
- // ARDY Core is 20 fps; segments must tile 0..clipFrames exactly, and each
1316
- // one needs at least 3 frames.
1317
- const ARDY_FPS =
1318
- (process.env.CCLAY_MOTION_BACKEND || "kimodo").trim().toLowerCase() === "kimodo" ? 24 : 20;
1319
- let segments;
1320
- let clipFrames;
1321
- let chained = 0;
1322
- if (timed) {
1323
- // A beat that became several blocks shares its time between them, so an
1324
- // explicit "6 seconds of walking" stays 6 seconds however it was phrased.
1325
- const pieceCount = kept.reduce((acc, piece) => {
1326
- acc[piece.source] = (acc[piece.source] ?? 0) + 1;
1327
- return acc;
1328
- }, {});
1329
- segments = [];
1330
- let cursor = 0;
1331
- for (const { text: prompt, source } of kept) {
1332
- const whole = phaseSeconds[source] ?? seconds / phases.length;
1333
- const share = whole / pieceCount[source];
1334
- // The studio caps a block at BLOCK_MAX_SECONDS and refuses to generate
1335
- // a longer one, so a long beat becomes consecutive blocks here rather
1336
- // than something the UI would reject.
1337
- const spans = splitLongBeat(share);
1338
- if (spans.length > 1) chained += spans.length - 1;
1339
- for (const span of spans) {
1340
- const startFrame = cursor;
1341
- cursor += Math.max(3, Math.round(span * ARDY_FPS));
1342
- segments.push({ startFrame, endFrame: cursor, prompt });
1343
- }
1344
- }
1345
- clipFrames = cursor;
1346
- } else {
1347
- clipFrames = Math.floor(seconds * ARDY_FPS);
1348
- // Even division must also respect the cap: enough blocks that no single
1349
- // one exceeds it, distributed evenly across the clip.
1350
- const perPhase = seconds / prompts.length;
1351
- const piecesPer = Math.ceil(perPhase / BLOCK_MAX_SECONDS);
1352
- if (piecesPer > 1) chained = prompts.length * (piecesPer - 1);
1353
- const total = prompts.length * piecesPer;
1354
- const per = Math.floor(clipFrames / total);
1355
- segments = [];
1356
- for (const [i, prompt] of prompts.entries()) {
1357
- for (let k = 0; k < piecesPer; k += 1) {
1358
- const index = i * piecesPer + k;
1359
- segments.push({
1360
- startFrame: index * per,
1361
- endFrame: index === total - 1 ? clipFrames : (index + 1) * per,
1362
- prompt,
1363
- });
1364
- }
1365
- }
1366
- }
1367
- const clipSeconds = clipFrames / ARDY_FPS;
1368
- // A single beat has no sequence to chain: send it as a plain prompt;
1369
- // composite physical wording remains intact in the normalized text.
1370
- // Long single prompts are the exception: split the actual clip length
1371
- // into equal Kimodo blocks so every wire segment stays within the cap.
1372
- const singlePromptNeedsChain = prompts.length === 1 && clipSeconds > BLOCK_MAX_SECONDS;
1373
- if (prompts.length === 1) {
1374
- if (singlePromptNeedsChain) {
1375
- segments = tileClipFrames(clipFrames, clipSeconds).map((b) => ({ ...b, prompt: prompts[0] }));
1376
- chained = segments.length - 1;
1377
- } else {
1378
- chained = 0;
1379
- }
1380
- }
1381
- const rewrites = kept
1382
- .map(({ text: prompt, notes }, i) => (notes.length ? ` ${i + 1}. ${prompt} ← ${notes.join("; ")}` : null))
1383
- .filter(Boolean);
1384
- const chainNote = chained > 0 ? `\n (${chained} block(s) chained to keep every block within ${BLOCK_MAX_SECONDS}s)` : "";
1385
- // A drop is reported on its own account: a caller can send nine already-perfect
1386
- // phases, which produces no rewrite and no chaining yet still loses the ninth
1387
- // to the schema's 8-phase limit, and silence there would hide a lost beat.
1388
- const dropNote =
1389
- normalized.dropped > 0 ? `\n (${normalized.dropped} beat(s) past the 8-phase limit were dropped)` : "";
1390
- const promptNote =
1391
- rewrites.length || chainNote || dropNote
1392
- ? (rewrites.length ? `\n\nRewritten for ARDY:\n${rewrites.join("\n")}` : "\n") + dropNote + chainNote
1393
- : "";
1394
-
1395
- if (!liveHub?.connected) return text(noLiveEditor("generate_motion requires a connected CozyClay editor so completion can be delivered over its live socket."));
1396
- const workspaceHandle = liveWorkspace.getStore() ?? liveHub.resolveWorkspace("generate_motion");
1397
- const workspaceId = liveHub.workspaceId(workspaceHandle);
1398
- // Reusing an existing take is installation, not generation demand. The
1399
- // shared lifecycle sanitizes before the targeted live transport sees it;
1400
- // disconnected events are omitted, never reconstructed on reconnect.
1401
- const motionRequest = motion_url ? null : startMotionRequest({ surface: "mcp", input_mode: "prompt" }, {
1402
- capture: (event, props) => liveHub?.sendEvent(workspaceId, "motion_telemetry", { event, props }),
1403
- });
1404
- try {
1405
- await refreshLiveDescription();
1406
- } catch (error) {
1407
- return liveError(error);
1408
- }
1409
- const targetCharacterId = stage().characters.find((character) => character.id === state.focus)?.id ?? stage().characters[0]?.id ?? null;
1410
- let job;
1411
- try {
1412
- job = motionJobs.create(workspaceId);
1413
- job.motionRequest = motionRequest;
1414
- } catch (error) {
1415
- return liveError(error);
1416
- }
1417
- const body =
1418
- prompts.length === 1 && !singlePromptNeedsChain
1419
- ? { prompt: prompts[0], duration: clipSeconds, posePin: false }
1420
- : { prompt: prompts.join(" "), duration: clipSeconds, segments, posePin: false };
1421
- if (seed !== undefined) body.seed = seed;
1422
-
1423
- // Abort tears down the HTTP stream. The bridge owns the child process group
1424
- // and kills it on disconnect, so cancellation stops generator work before a
1425
- // terminal cancelled event is sent; it never reaches editor installation.
1426
- const run = async () => {
1427
- if (job.status === "cancelled") return;
1428
- const controller = new AbortController();
1429
- const deadline = setTimeout(() => controller.abort(new Error("Motion generation exceeded the 5 minute deadline.")), 5 * 60_000);
1430
- deadline.unref?.();
1431
- job.cancel = () => {
1432
- controller.abort();
1433
- motionRequest?.fail(null, "aborted");
1434
- };
1435
- try {
1436
- let motionUrl = motion_url;
1437
- if (!motionUrl) {
1438
- let health;
1439
- try {
1440
- const response = await fetch(`${bridge}/ardy/health`, {
1441
- signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]),
1442
- });
1443
- const payload = await response.json();
1444
- health = { ...payload, ok: response.ok && payload?.ok !== false };
1445
- } catch (error) {
1446
- if (controller.signal.aborted) throw error;
1447
- health = { ok: false };
1448
- }
1449
- if (job.status === "cancelled") return;
1450
- // Readiness controls execution independently of best-effort telemetry.
1451
- const reason = motionPreflightReason(health, { body });
1452
- motionRequest?.preflight(health, { body });
1453
- if (reason) {
1454
- motionJobs.transition(job, "failed", { message: `Motion generation preflight blocked: ${reason}.` });
1455
- await publishMotionJob(job);
1456
- return;
1457
- }
1458
- motionJobs.transition(job, "running");
1459
- motionRequest?.start();
1460
- const res = await fetch(`${bridge}/ardy/generate`, {
1461
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: controller.signal,
1462
- });
1463
- motionUrl = await readMotionStream(res);
1464
- }
1465
- if (job.status === "cancelled") return;
1466
- if (typeof motionUrl !== "string") throw new Error("Generation ended without a motion.");
1467
- if (!motionUrlPattern.test(motionUrl)) throw new Error("Generator returned an invalid motion URL.");
1468
- motionRequest?.succeed();
1469
- motionJobs.transition(job, "completed", {
1470
- motionUrl, prompt: prompts.join(" "), blocks: segments, drop, targetCharacterId,
1471
- summary: `${clipSeconds.toFixed(1)}s / ${clipFrames} frames${promptNote}`,
1472
- });
1473
- await publishMotionJob(job);
1474
- } catch (error) {
1475
- if (job.status === "cancelled" || controller.signal.aborted || error?.name === "AbortError") {
1476
- motionRequest?.fail(error, "aborted");
1477
- if (job.status !== "cancelled") motionJobs.transition(job, "cancelled", { message: "Generation cancelled before editor delivery." });
1478
- } else {
1479
- motionRequest?.fail(error);
1480
- motionJobs.transition(job, "failed", { message: error instanceof Error ? error.message : "Motion generation failed." });
1481
- }
1482
- await publishMotionJob(job);
1483
- } finally {
1484
- clearTimeout(deadline);
1485
- job.cancel = null;
1486
- }
1487
- };
1488
- queueMicrotask(run);
1489
- return text(JSON.stringify(motionJobs.task(job)));
1276
+ async ({ phases, seconds = 9, seed, motion_url, drop, ...admission }) => {
1277
+ if (!liveHub?.connected) return text(noLiveEditor("Motion requires a connected CozyClay editor."));
1278
+ const normalized = normalizePhases(phases.map(phase => typeof phase === 'string' ? phase : phase.text));
1279
+ const beats = normalized.texts.map((text, index) => ({ text, seconds: phases[normalized.sources[index]]?.seconds ?? seconds / phases.length })).filter(beat => beat.text);
1280
+ return runStudioCommand({ ...admission, action: motion_url ? 'motion.replace' : 'motion.generate', args: context => {
1281
+ const generated = generationArgs({ characterId: context.activeCharacterId, source: { kind: 'generate', beats, ...(seed === undefined ? {} : { seed }) } });
1282
+ return motion_url ? { characterId: generated.characterId, url: motion_url, prompt: beats.map(beat => beat.text).join(' '),
1283
+ blocks: generated.blocks.map(({ startFrame, endFrame, text: prompt }) => ({ startFrame, endFrame, prompt })), ...(drop ? { drop } : {}) }
1284
+ : { ...generated, ...(drop ? { drop } : {}) };
1285
+ } });
1490
1286
  },
1491
1287
  ),
1492
1288
 
@@ -1534,7 +1330,7 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1534
1330
  hidden: z.boolean().optional().describe("true hides the prop without deleting it"),
1535
1331
  },
1536
1332
  },
1537
- async ({ id, x, y, z: zPos, facing, tilt, roll, scale, scale_x, scale_y, scale_z, color, name, path, height, clay, hidden }) => {
1333
+ async ({ id, x, y, z: zPos, facing, tilt, roll, scale, scale_x, scale_y, scale_z, color, name, path, height, clay, hidden, ...admission }) => {
1538
1334
  const travelPath = path === null
1539
1335
  ? null
1540
1336
  : path
@@ -1542,15 +1338,11 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1542
1338
  : undefined;
1543
1339
  if (liveHub?.connected) {
1544
1340
  try {
1545
- await appliedLiveMutation("update_object", {
1546
- id, x, y, z: zPos, rot: facing, rotX: tilt, rotZ: roll,
1547
- scale, scaleX: scale_x, scaleY: scale_y, scaleZ: scale_z, color, name,
1548
- ...(travelPath !== undefined ? { path: travelPath } : {}),
1549
- ...(height !== undefined ? { height } : {}),
1550
- ...(clay !== undefined ? { clay } : {}),
1551
- ...(hidden !== undefined ? { hidden } : {}),
1552
- });
1553
- return text(`Updated ${id}.\n\n${sceneReport()}`);
1341
+ return await runStudioCommand({ ...admission, action: "object.update", args: { id, patch: {
1342
+ x, y, z: zPos, rot: facing, rotX: tilt, rotZ: roll,
1343
+ scaleX: scale_x ?? scale, scaleY: scale_y ?? scale, scaleZ: scale_z ?? scale,
1344
+ color, name, path: travelPath, height, clay, hidden,
1345
+ } } });
1554
1346
  } catch (error) {
1555
1347
  return liveError(error);
1556
1348
  }
@@ -1591,11 +1383,10 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1591
1383
  description: "Take a prop out of the set.",
1592
1384
  inputSchema: { id: z.string().describe("object id") },
1593
1385
  },
1594
- async ({ id }) => {
1386
+ async ({ id, ...admission }) => {
1595
1387
  if (liveHub?.connected) {
1596
1388
  try {
1597
- await appliedLiveMutation("remove_object", { id });
1598
- return text(`Removed ${id}.\n\n${sceneReport()}`);
1389
+ return await runStudioCommand({ ...admission, action: "object.remove", args: { ids: [id] } });
1599
1390
  } catch (error) {
1600
1391
  return liveError(error);
1601
1392
  }
@@ -1639,18 +1430,12 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1639
1430
  label: z.string().min(1).default("MCP batch").describe("the single editor undo entry name"),
1640
1431
  },
1641
1432
  },
1642
- async ({ ops, atomic, stopOnError, label }) => {
1433
+ async ({ ops, atomic, stopOnError, label, ...admission }) => {
1643
1434
  if (!liveHub?.connected) return text(noLiveEditor("apply_batch requires a connected CozyClay editor."));
1644
1435
  try {
1645
- const result = await appliedLiveMutation("apply_batch", { ops, atomic, stopOnError, label });
1646
- const applied = Array.isArray(result?.applied) ? result.applied : [];
1647
- const failed = Array.isArray(result?.failed) ? result.failed : [];
1648
- const failure = failed[0];
1649
- const summary = result?.rolledBack
1650
- ? `Batch rolled back after failure at operation ${failure?.index ?? "unknown"}.`
1651
- : `Applied ${applied.length} operation(s).`;
1652
- const detail = failure ? ` Failure at operation ${failure.index}: ${failure.error}.` : "";
1653
- return text(`${summary}${detail}\n\n${sceneReport()}`);
1436
+ // #446: the v1 alias is objects-only, so objects.batch (not a
1437
+ // generic run.batch) preserves its options and single undo entry.
1438
+ return await runStudioCommand({ ...admission, action: "objects.batch", args: { ops, atomic, stopOnError, label } });
1654
1439
  } catch (error) {
1655
1440
  return liveError(error);
1656
1441
  }
@@ -1780,30 +1565,30 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1780
1565
  {
1781
1566
  title: "Add a scene",
1782
1567
  description:
1783
- "Add another scene to the project and make it active. With a connected editor, the complete " +
1784
- "scene document is forwarded through that workspace's load_scenes command before success is reported; " +
1785
- "without one, this changes MCP memory only.",
1568
+ "Add another scene to the project and make it active. A connected editor runs scene.create then scene.rename " +
1569
+ "and returns both bus receipts; without one, this changes MCP memory only.",
1786
1570
  inputSchema: { name: z.string().default("SCENE 02").describe("scene name") },
1787
1571
  },
1788
- async ({ name }) => {
1572
+ async ({ name, ...admission }) => {
1789
1573
  if (liveHub?.connected) {
1574
+ let created;
1790
1575
  try {
1791
- await refreshLiveDescription();
1576
+ created = await executeStudioCommand({ ...admission, action: "scene.create" });
1577
+ if (!created.ok) return studioResult(created);
1578
+ // Creation is a document boundary; naming is a separate retained edit.
1579
+ const renamed = await executeStudioCommand({ ...admission, action: "scene.rename",
1580
+ commandId: admission.commandId ? createHash("sha256").update(`${admission.commandId}:rename`).digest("hex") : undefined,
1581
+ args: { sceneId: created.host.sceneId, name }, expectedRevision: created.revision.after });
1582
+ return studioResult(created, renamed);
1792
1583
  } catch (error) {
1793
- return liveError(error);
1584
+ const failure = liveError(error);
1585
+ if (created) failure.content.unshift(...studioResult(created).content);
1586
+ return failure;
1794
1587
  }
1795
1588
  }
1796
1589
  const document = JSON.parse(JSON.stringify(state.doc));
1797
1590
  document.scenes = addScene(document.scenes, name);
1798
1591
  document.activeSceneId = document.scenes[document.scenes.length - 1].id;
1799
- if (liveHub?.connected) {
1800
- try {
1801
- const live = await appliedLiveMutation("load_scenes", { document });
1802
- requireLiveSceneParity("add_scene", document, live);
1803
- } catch (error) {
1804
- return liveError(error);
1805
- }
1806
- }
1807
1592
  state.doc = document;
1808
1593
  return text(`Added "${scene().name}".\n\n${sceneReport()}`);
1809
1594
  },
@@ -1814,14 +1599,18 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1814
1599
  {
1815
1600
  title: "Switch the active scene",
1816
1601
  description:
1817
- "Make a different scene active. With a connected editor, the complete scene document is forwarded " +
1818
- "through that workspace's load_scenes command before success is reported; without one, this changes MCP memory only.",
1602
+ "Make a different scene active through scene.switch and return its bus receipt. Without a connected editor, this changes MCP memory only.",
1819
1603
  inputSchema: { name: z.string().describe("scene name to switch to") },
1820
1604
  },
1821
- async ({ name }) => {
1605
+ async ({ name, ...admission }) => {
1822
1606
  if (liveHub?.connected) {
1823
1607
  try {
1824
- await refreshLiveDescription();
1608
+ return await runStudioCommand({ ...admission, action: "scene.switch", args: async () => {
1609
+ await refreshLiveDescription();
1610
+ const target = state.doc.scenes.find(row => row.name.toLowerCase() === name.toLowerCase());
1611
+ if (!target) throw new Error(`No scene "${name}".`);
1612
+ return { sceneId: target.id };
1613
+ } });
1825
1614
  } catch (error) {
1826
1615
  return liveError(error);
1827
1616
  }
@@ -1832,14 +1621,6 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1832
1621
  }
1833
1622
  const document = JSON.parse(JSON.stringify(state.doc));
1834
1623
  document.activeSceneId = target.id;
1835
- if (liveHub?.connected) {
1836
- try {
1837
- const live = await appliedLiveMutation("load_scenes", { document });
1838
- requireLiveSceneParity("switch_scene", document, live);
1839
- } catch (error) {
1840
- return liveError(error);
1841
- }
1842
- }
1843
1624
  state.doc = document;
1844
1625
  return text(`Switched to "${scene().name}".\n\n${sceneReport()}`);
1845
1626
  },
@@ -1850,10 +1631,13 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1850
1631
  {
1851
1632
  title: "Open a .cclayproject file",
1852
1633
  description:
1853
- "Load a project authored in the CozyClay studio (or saved here). Replaces the current state.",
1854
- inputSchema: { path: z.string().describe("path to a .cclayproject file") },
1634
+ "Load a project authored in the CozyClay studio (or saved here). Replacing scene ids in a connected editor requires a Studio-issued confirmationToken; returns the load_scenes bus receipt.",
1635
+ inputSchema: {
1636
+ path: z.string().describe("path to a .cclayproject file"),
1637
+ confirmationToken: z.string().min(1).max(120).optional().describe("token issued by the Studio UI for this exact scene document"),
1638
+ },
1855
1639
  },
1856
- async ({ path }) => {
1640
+ async ({ path, ...admission }) => {
1857
1641
  let full;
1858
1642
  let raw;
1859
1643
  try {
@@ -1879,8 +1663,14 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1879
1663
  const nextDocument = scenes.document;
1880
1664
  if (liveHub?.connected) {
1881
1665
  try {
1882
- const live = await appliedLiveMutation("load_scenes", { document: nextDocument });
1883
- requireLiveSceneParity("open_project", nextDocument, live);
1666
+ const loaded = await executeStudioCommand({ ...admission, action: "load_scenes", args: { document: nextDocument } });
1667
+ if (loaded.ok) {
1668
+ state.name = result.project.name;
1669
+ state.focus = null;
1670
+ state.focusLocked = false;
1671
+ state.markedFraming = null;
1672
+ }
1673
+ return studioResult(loaded);
1884
1674
  } catch (error) {
1885
1675
  return liveError(error);
1886
1676
  }
@@ -1972,5 +1762,58 @@ export const createToolHandlers = ({ projectRootPromise, motionJobs, publishMoti
1972
1762
  return text(`Saved "${state.name}" to ${full} (${state.doc.scenes.length} scene(s)).`);
1973
1763
  },
1974
1764
  ),
1765
+
1766
+ tool(
1767
+ "studio_commands",
1768
+ {
1769
+ title: "List Studio commands",
1770
+ description:
1771
+ "List every command the connected editor registers, for studio_run: id, label, kind (mutation, transient, job, document), " +
1772
+ "exposure, whether it is available now (the reason when not), and timeoutMs or generation where declared. The list carries " +
1773
+ "no schemas; pass ids to read those commands' full declarations, description and input schema included.",
1774
+ inputSchema: {
1775
+ ids: z.array(z.string().min(1).max(120)).min(1).max(32).optional().describe("command ids whose full declarations to read"),
1776
+ },
1777
+ },
1778
+ async ({ ids }) => {
1779
+ if (!liveHub?.connected) return liveError(new Error(noLiveEditor("studio_commands requires a connected CozyClay editor.")));
1780
+ try {
1781
+ const listed = await liveHub.command("inspect_studio", { scope: "actions", ...(ids ? { ids } : {}) }, liveWorkspace.getStore());
1782
+ return text(JSON.stringify({ actions: listed?.actions ?? [] }));
1783
+ } catch (error) {
1784
+ return liveError(error);
1785
+ }
1786
+ },
1787
+ ),
1788
+
1789
+ tool(
1790
+ "studio_run",
1791
+ {
1792
+ title: "Run a Studio command",
1793
+ description:
1794
+ "Run one command the connected editor registers, by id, through the editor's command bus: the same door its UI and agent use. " +
1795
+ "args must match the command's input schema (read it with studio_commands and ids). The command is admitted at the open " +
1796
+ "document and its current revision unless expectedRevision is given, and answers the editor's JSON receipt: status, summary, " +
1797
+ "affectedIds, undo, and output for a job. A refusal is a receipt with ok false and its code: STALE_SCENE means read again and " +
1798
+ "re-issue; CONFIRMATION_REQUIRED means obtain the user's approval in Studio and pass its confirmationToken. Reusing a commandId returns the receipt of " +
1799
+ "that earlier call instead of running again.",
1800
+ inputSchema: {
1801
+ action: z.string().min(1).max(120).describe("command id, as studio_commands lists it"),
1802
+ args: z.record(z.string(), z.unknown()).default({}).describe("the command's arguments, matching its input schema"),
1803
+ expectedRevision: z.number().int().min(0).optional().describe("the scene revision the command is admitted at; defaults to the current one"),
1804
+ commandId: z.string().min(1).max(120).optional().describe("idempotency key; a repeated id answers the first call's receipt"),
1805
+ timeoutMs: z.number().int().min(1).max(MAX_COMMAND_TIMEOUT_MS).optional().describe("how long the hub waits; defaults to the command's declared timeout"),
1806
+ confirmationToken: z.string().min(1).max(120).optional().describe("Studio UI approval token for this exact command; MCP cannot mint one"),
1807
+ },
1808
+ },
1809
+ async (args) => {
1810
+ if (!liveHub?.connected) return liveError(new Error(noLiveEditor("studio_run requires a connected CozyClay editor.")));
1811
+ try {
1812
+ return await runStudioCommand(args);
1813
+ } catch (error) {
1814
+ return liveError(error);
1815
+ }
1816
+ },
1817
+ ),
1975
1818
  ];
1976
1819
  };