cozyclay 3.0.2 → 3.1.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.
Files changed (221) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +22 -56
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/session-store.mjs +2 -2
  10. package/bin/agent/studio-prompt.mjs +1 -1
  11. package/bin/agent/studio-task-routes.mjs +185 -0
  12. package/bin/agent/studio-tasks.mjs +156 -0
  13. package/bin/agent/studio-tools.mjs +3 -2
  14. package/bin/agent/studio-turn-transaction.mjs +76 -0
  15. package/bin/cozyclay.mjs +5 -7
  16. package/dist/app/index.html +7 -6
  17. package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
  18. package/dist/assets/app-B2BpfCl2.js +5088 -0
  19. package/dist/assets/app-BlwOpest.css +1 -0
  20. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  21. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  22. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  23. package/dist/assets/motion3-compile-B887n77J.js +1 -0
  24. package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
  25. package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
  26. package/dist/assets/task-board-BKaM7d-9.js +14 -0
  27. package/dist/assets/workflow-B8Dhzexj.js +179 -0
  28. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  29. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  30. package/dist/cozyclay-package.json +1 -1
  31. package/dist/greybox-to-video/index.html +2 -3
  32. package/dist/index.html +7 -57
  33. package/dist/llms.txt +2 -1
  34. package/dist/previs-software/index.html +2 -2
  35. package/dist/privacy/index.html +3 -7
  36. package/dist/seedance-camera-control/index.html +2 -2
  37. package/dist/sitemap.xml +5 -5
  38. package/dist/sw.js +5 -1
  39. package/dist/thumbs/blank-stage-dark.webp +0 -0
  40. package/dist/thumbs/blank-stage-light.webp +0 -0
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/mesh-file.mjs +3 -1
  43. package/mcp/server.mjs +3 -0
  44. package/package.json +3 -3
  45. package/src/App.jsx +50 -281
  46. package/src/analytics.js +3 -148
  47. package/src/app-stage.jsx +1 -1
  48. package/src/asset-pane.jsx +5 -10
  49. package/src/auto-flat.js +10 -0
  50. package/src/bus-task-scopes.js +69 -0
  51. package/src/bus-task-targets.js +48 -0
  52. package/src/bus-tasks.js +181 -0
  53. package/src/command-bus.js +15 -40
  54. package/src/command-history.js +36 -0
  55. package/src/commands/cast.js +10 -3
  56. package/src/commands/motion.js +2 -1
  57. package/src/commands/motion3-compile.js +51 -27
  58. package/src/commands/motion3.js +13 -7
  59. package/src/commands/project.js +1 -1
  60. package/src/commands/shared.js +9 -0
  61. package/src/commands/shot.js +16 -4
  62. package/src/controls.jsx +0 -11
  63. package/src/cuts.js +11 -0
  64. package/src/domains/cast.js +3 -2
  65. package/src/domains/motion.js +3 -10
  66. package/src/domains/playback.js +32 -0
  67. package/src/domains/scenes.js +3 -22
  68. package/src/domains/shots.js +5 -5
  69. package/src/dualview.jsx +11 -15
  70. package/src/hierarchy-panel.css +18 -4
  71. package/src/hierarchy-panel.jsx +12 -11
  72. package/src/kits/held.js +3 -2
  73. package/src/light-blockers.js +2 -2
  74. package/src/main.jsx +5 -0
  75. package/src/motion/ground.js +5 -14
  76. package/src/motion/playback-frame-store.js +79 -0
  77. package/src/motion/playback-probe.js +16 -0
  78. package/src/motion/playback-subject.js +7 -0
  79. package/src/motion/timeline.css +43 -5
  80. package/src/motion/timeline.jsx +4 -3
  81. package/src/motion3/body.js +37 -5
  82. package/src/motion3/carried.js +11 -3
  83. package/src/motion3/compile-job.js +1 -1
  84. package/src/motion3/compiler/anchors.js +33 -5
  85. package/src/motion3/compiler/compile.js +27 -4
  86. package/src/motion3/compiler/finger-close.js +137 -0
  87. package/src/motion3/compiler/grip.js +9 -3
  88. package/src/motion3/compiler/limbs.js +18 -3
  89. package/src/motion3/compiler/pose.js +6 -0
  90. package/src/motion3/compiler/socket-grip.js +34 -10
  91. package/src/motion3/compiler/validate-objects.js +17 -2
  92. package/src/motion3/compiler/validate-parts.js +2 -1
  93. package/src/motion3/compiler/vocab.js +4 -4
  94. package/src/motion3/describe.js +2 -2
  95. package/src/motion3/docs.js +1 -1
  96. package/src/motion3/fix/index.js +7 -3
  97. package/src/motion3/flight.js +38 -0
  98. package/src/motion3/free-motion.js +10 -7
  99. package/src/motion3/load.js +125 -0
  100. package/src/motion3/mass.js +23 -0
  101. package/src/motion3/object-keys.js +11 -6
  102. package/src/motion3/object-pose.js +11 -3
  103. package/src/motion3/pipeline.js +5 -0
  104. package/src/motion3/qa/explain.js +3 -1
  105. package/src/motion3/qa/grasp-checks.js +123 -0
  106. package/src/motion3/qa/hints-grasp.js +71 -0
  107. package/src/motion3/qa/index.js +3 -1
  108. package/src/motion3/qa/object-checks.js +7 -2
  109. package/src/motion3/qa/patch.js +2 -1
  110. package/src/motion3/qa/spec-checks.js +21 -1
  111. package/src/motion3/schema/fields.js +3 -3
  112. package/src/motion3/schema/topics.js +11 -10
  113. package/src/motion3/schema/types.js +5 -1
  114. package/src/motion3/seat.js +84 -0
  115. package/src/motion3/settle.js +11 -6
  116. package/src/motion3/shapes.js +70 -0
  117. package/src/motion3/skills/basic.js +61 -13
  118. package/src/motion3/skills/catch.js +71 -0
  119. package/src/motion3/skills/index.js +13 -4
  120. package/src/motion3/skills/info.js +14 -1
  121. package/src/motion3/skills/reach-human.js +139 -0
  122. package/src/motion3/skills/stance.js +92 -0
  123. package/src/motion3/skills/throw.js +72 -0
  124. package/src/motion3/sockets.js +21 -4
  125. package/src/motion3/views.js +28 -5
  126. package/src/object-catalog.jsx +0 -4
  127. package/src/object-gizmo.jsx +2 -1
  128. package/src/object-path.js +1 -1
  129. package/src/panels/ProjectPanel.jsx +42 -21
  130. package/src/panels/details.css +15 -4
  131. package/src/popover.js +37 -0
  132. package/src/posestudio.jsx +15 -10
  133. package/src/project-browser-art.jsx +19 -0
  134. package/src/project-browser.css +74 -121
  135. package/src/project-browser.jsx +36 -60
  136. package/src/props.jsx +4 -7
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +3 -7
  139. package/src/scenes.js +4 -5
  140. package/src/settings-menu.jsx +1 -20
  141. package/src/shell/BottomDock.jsx +9 -9
  142. package/src/shell/MenuBar.jsx +1 -9
  143. package/src/shell/PreferencesDialog.jsx +3 -4
  144. package/src/shell/ViewportToolbar.jsx +3 -3
  145. package/src/shell/add-camera-shot.js +11 -0
  146. package/src/shell/glass-regions.css +3 -2
  147. package/src/shell/glass.css +0 -5
  148. package/src/shell/lens-scrub-math.js +51 -0
  149. package/src/shell/lens-scrub.css +73 -0
  150. package/src/shell/lens-scrub.jsx +98 -0
  151. package/src/shell/mode.css +6 -5
  152. package/src/shell/preferences.css +1 -32
  153. package/src/shell/shell.css +18 -1
  154. package/src/shell/studio-shell-context.js +5 -0
  155. package/src/shot-frame.js +26 -0
  156. package/src/stage-canvas-rigs.jsx +7 -8
  157. package/src/stage-characters.jsx +26 -0
  158. package/src/stage-gizmo.js +43 -0
  159. package/src/stage-labels-ko.js +0 -6
  160. package/src/stage-startup-defaults.js +3 -9
  161. package/src/starter-scenes.js +5 -11
  162. package/src/studio-agent-commands.js +2 -2
  163. package/src/studio-agent-protocol.js +2 -7
  164. package/src/studio-app-binding.js +3 -3
  165. package/src/studio-elements.js +1 -1
  166. package/src/studio-error-codes.js +1 -1
  167. package/src/studio-motion-visual.js +4 -4
  168. package/src/styles/themes.css +13 -0
  169. package/src/styles.css +6 -674
  170. package/src/text/text-object.jsx +1 -1
  171. package/src/ui.jsx +8 -16
  172. package/src/use-embed-bridge.js +3 -6
  173. package/src/use-render-activity.js +3 -1
  174. package/src/view-ghosts.jsx +5 -3
  175. package/src/viewport-visible-rect.js +37 -0
  176. package/src/workflow/AgentPanel.jsx +75 -42
  177. package/src/workflow/AgentProgressCard.jsx +1 -1
  178. package/src/workflow/TaskBoard.jsx +102 -0
  179. package/src/workflow/TaskInline.jsx +114 -0
  180. package/src/workflow/agent-client.js +11 -25
  181. package/src/workflow/agent-panel.css +14 -1
  182. package/src/workflow/attachment-image.js +10 -3
  183. package/src/workflow/attachment-video.js +91 -0
  184. package/src/workflow/task-board.css +98 -0
  185. package/src/workflow/task-board.js +169 -0
  186. package/tools/check-eslint.mjs +2 -1
  187. package/tools/check-knip.mjs +2 -1
  188. package/tools/dev/pages/greybox-to-video.html +2 -3
  189. package/tools/dev/pages/previs-software.html +2 -2
  190. package/tools/dev/pages/privacy.html +3 -7
  191. package/tools/dev/pages/seedance-camera-control.html +2 -2
  192. package/tools/dev-full.mjs +11 -2
  193. package/tools/eslint-baseline.json +6 -6
  194. package/tools/knip-baseline.json +0 -11
  195. package/tools/loc-baseline.json +30 -31
  196. package/tools/metrics-snapshot.mjs +43 -5
  197. package/tools/render-start-thumbs.mjs +175 -0
  198. package/tools/run-tests.mjs +7 -14
  199. package/tools/sitemap.mjs +1 -1
  200. package/dist/assets/analytics-DbKG0NgD.js +0 -6
  201. package/dist/assets/app-34i-iUy1.js +0 -5088
  202. package/dist/assets/app-f-fkV5uP.css +0 -1
  203. package/dist/assets/compile-worker-DYpa2nvO.js +0 -5
  204. package/dist/assets/first-shot-handoff-CrGZ3n_O.js +0 -1
  205. package/dist/assets/landing-mq5bnqFE.js +0 -1
  206. package/dist/assets/scene-asset-sync-7J-w5oNh.js +0 -20
  207. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  208. package/dist/assets/workflow-dUhurYUc.js +0 -179
  209. package/dist/scenes/city-block.cclayproject +0 -1
  210. package/src/burn-in.js +0 -91
  211. package/src/camera-tutorial.jsx +0 -520
  212. package/src/facing-marks.js +0 -88
  213. package/src/first-shot-handoff.js +0 -44
  214. package/src/panels/motion.css +0 -197
  215. package/src/set-pieces.jsx +0 -183
  216. package/src/tutorial-analytics.js +0 -86
  217. package/src/tutorial-walk.js +0 -17
  218. package/src/use-camera-tutorial.js +0 -50
  219. package/src/use-semantic-state.js +0 -12
  220. package/tools/metrics/archive-traffic.mjs +0 -138
  221. package/tools/perf/ik-camera-drag.mjs +0 -138
package/src/analytics.js CHANGED
@@ -116,7 +116,6 @@ const EVENT_PROPERTIES = Object.freeze({
116
116
  "project:opened": ["age_bucket"],
117
117
  "craft:first_action": ["action_kind"],
118
118
  "craft:first_edit": ["edit_kind", "definition_version"],
119
- "motion:backend_state": ["backend", "host_configured"],
120
119
  "motion:generate_requested": ["surface", "input_mode", "request_id"],
121
120
  "motion:preflight_blocked": ["reason", "surface", "request_id"],
122
121
  "motion:preflight_passed": ["backend", "surface", "request_id"],
@@ -136,19 +135,14 @@ const EVENT_PROPERTIES = Object.freeze({
136
135
  "playground:first_action": ["action_kind"],
137
136
  "playground:first_edit": ["edit_kind", "definition_version"],
138
137
  "activation:completed": ["activation_path"],
139
- "tutorial:started": ["surface", "tutorial_version", "start_source"],
140
- "tutorial:step_entered": ["surface", "tutorial_version", "step_kind"],
141
- "tutorial:step_completed": ["surface", "tutorial_version", "step_kind", "elapsed_bucket"],
142
- "tutorial:completed": ["surface", "tutorial_version", "elapsed_bucket"],
143
- "tutorial:dismissed": ["surface", "tutorial_version", "step_kind"],
144
138
  "sculpt_run": ["outcome", "rounds", "final_iou", "duration_ms", "had_target"],
145
139
  ...EXECUTION_TELEMETRY_PROPERTY_KEYS,
146
140
  });
147
141
  const FEATURE_NAMES = new Set([
148
142
  "pose_edit", "pose_save", "camera_fly", "orbit", "dolly_rail", "crane_graph", "timeline_scrub",
149
143
  "prompt_block_add", "shot_add", "shot_cut", "export_pose", "export_frame", "export_video",
150
- "export_depth_video", "export_keyframe_pack", "fal_motion_open",
151
- "mcp_connected", "auto_color", "plan_view", "camera_tutorial",
144
+ "export_depth_video", "export_keyframe_pack",
145
+ "mcp_connected", "auto_color", "plan_view",
152
146
  ]);
153
147
  // `site` and `playground` arrive through `npx cozyclay --via <source>` from the
154
148
  // landing page's copy commands; the others are the first-launch prompt answers.
@@ -159,7 +153,6 @@ const MOTION_ERROR_CODES = new Set(["aborted", "unsupported_route", "generation_
159
153
  const MOTION_PROPERTY_VALUES = Object.freeze({
160
154
  // `fal` is the Studio's AI video route (H3 Max Turbo through api.cozyclay.org).
161
155
  backend: new Set(["none", "local_kimodo", "hosted", "fal"]),
162
- host_configured: new Set([true, false]),
163
156
  surface: new Set(["timeline", "line_edit", "trail", "mcp", "fal_card", "agent"]),
164
157
  input_mode: new Set(["prompt", "pose", "edit", "a_to_b", "still"]),
165
158
  // `locked`: the AI video route is not enabled for this account yet.
@@ -176,13 +169,6 @@ const EXPORT_PROPERTY_VALUES = Object.freeze({
176
169
  duration_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
177
170
  failure_code: EXPORT_FAILURE_CODES,
178
171
  });
179
- const TUTORIAL_PROPERTY_VALUES = Object.freeze({
180
- surface: new Set(["studio", "playground"]),
181
- tutorial_version: new Set([1]),
182
- start_source: new Set(["query", "settings", "landing"]),
183
- step_kind: new Set(["fly", "walk", "dolly", "orbit", "shot", "rail", "play"]),
184
- elapsed_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
185
- });
186
172
  // Sculpt mode (#739): one sculpt_run per finished run. Numbers only: no
187
173
  // reference image, recipe, part name or sculpt id ever rides along.
188
174
  export const SCULPT_RUN_OUTCOMES = Object.freeze(["done", "cancelled", "failed"]);
@@ -242,7 +228,7 @@ let deviceProfileStarted = false;
242
228
  // move action_count_bucket.
243
229
  const PASSIVE_EVENTS = new Set([
244
230
  "app:session_started", "app:session_ended", "install:first_launch",
245
- "motion:backend_state", "app:error", "device:profile",
231
+ "app:error", "device:profile",
246
232
  ]);
247
233
 
248
234
  function storage() {
@@ -320,7 +306,6 @@ export function sanitizeProps(event, props) {
320
306
  if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
321
307
  } else if (!MOTION_PROPERTY_VALUES[key]?.has(props[key])) continue;
322
308
  }
323
- if (event.startsWith("tutorial:") && !TUTORIAL_PROPERTY_VALUES[key]?.has(props[key])) continue;
324
309
  if (event.startsWith("workflow:") || event.startsWith("agent:") || event.startsWith("mcp:")) {
325
310
  if (key === "run_id" || key === "turn_id" || key === "request_id") {
326
311
  if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
@@ -372,136 +357,6 @@ export function sanitizeProps(event, props) {
372
357
  return sanitized;
373
358
  }
374
359
 
375
- /**
376
- * Convert the bridge health payload into the analytics contract. The bridge
377
- * only exposes a safe location label; the configured host itself never leaves
378
- * the local process. A missing/unhealthy bridge is the useful `none` bucket.
379
- */
380
- export function motionBackendState(health) {
381
- if (!health || health.ok !== true) return { backend: "none", host_configured: false };
382
- if (typeof health.backend === "string" && ["none", "local_kimodo", "hosted"].includes(health.backend)) {
383
- return {
384
- backend: health.backend,
385
- host_configured: health.host_configured === true
386
- || (typeof health.host === "string" && health.host.trim().length > 0),
387
- };
388
- }
389
- const host = typeof health.host === "string" ? health.host.trim() : "";
390
- return {
391
- // The current /ardy bridge is the local Kimodo integration even when it
392
- // dispatches to a configured GPU box over SSH. A future hosted API can
393
- // opt into the explicit `backend: "hosted"` field above.
394
- backend: "local_kimodo",
395
- host_configured: Boolean(host),
396
- };
397
- }
398
-
399
- /** Read only structured readiness; raw health/error prose never classifies intent. */
400
- export function motionPreflightReason(health, { body = {}, lineEditSupported = false } = {}) {
401
- if (!health || health.backend === "none" || (health.ok !== true && (health.host_configured === false || health.reason === "unconfigured"))) return "unconfigured";
402
- if (health.ok !== true) return "unreachable";
403
- if ((body.lineEdit || body.replay?.length) && !lineEditSupported) return "unsupported_route";
404
- // The #267 local MLX/cpp route supports a single unconstrained prompt.
405
- // ProjFlow line edits have their own capability/runner, even on a local bridge.
406
- if (!body.lineEdit && health.host === "local" && health.device === "local" && (
407
- body.segments?.length > 1 || body.posePin || body.waypoints?.length || body.motionEdit || body.preserve
408
- )) return "unsupported_route";
409
- return null;
410
- }
411
-
412
- export function motionFailureCode(error, fallbackCode) {
413
- try {
414
- if (error?.name === "AbortError") return "aborted";
415
- if (MOTION_ERROR_CODES.has(fallbackCode)) return fallbackCode;
416
- if (error instanceof Error) return "generation_failed";
417
- } catch {
418
- // Cross-realm error objects may have throwing getters.
419
- }
420
- return "unknown";
421
- }
422
-
423
- /** One explicit request, not an authoring draft. All methods are telemetry-only:
424
- * no return value may decide whether generation runs. Duplicate/out-of-order
425
- * callbacks cannot advance the funnel, and application is separate from success.
426
- */
427
- export function startMotionRequest(metadata, dependencies = {}) {
428
- let phase = "requested";
429
- let props = null;
430
- let startedAt = NaN;
431
- let now = () => performance.now();
432
- let capture = track;
433
- const clock = () => { try { return now(); } catch { return NaN; } };
434
- const emit = (event, extra = {}) => {
435
- if (!props) return;
436
- try {
437
- Promise.resolve(capture(event, sanitizeProps(event, { ...props, ...extra }))).catch(() => {
438
- // A rejected transport must not change generation.
439
- });
440
- } catch {
441
- // Telemetry is best effort, including capture/payload failures.
442
- }
443
- };
444
- try {
445
- now = dependencies.now ?? now;
446
- capture = dependencies.capture ?? capture;
447
- const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
448
- const request_id = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
449
- props = { ...sanitizeProps("motion:generate_requested", metadata), request_id };
450
- emit("motion:generate_requested");
451
- } catch {
452
- // Omit telemetry without secure randomness; never derive an ID from content.
453
- props = null;
454
- }
455
- return {
456
- preflight(health, options) {
457
- if (phase !== "requested") return;
458
- try {
459
- const reason = motionPreflightReason(health, options);
460
- phase = reason ? "blocked" : "passed";
461
- if (props) props.backend = motionBackendState(health).backend;
462
- emit(reason ? "motion:preflight_blocked" : "motion:preflight_passed", reason ? { reason } : {});
463
- } catch {
464
- // A malformed telemetry input cannot stop the real request.
465
- }
466
- },
467
- /** A route that decides readiness without the /ardy health payload (the
468
- * Fal card): refuse with one closed reason, such as `locked`. */
469
- block(reason) {
470
- if (phase !== "requested") return;
471
- phase = "blocked";
472
- emit("motion:preflight_blocked", { reason });
473
- },
474
- /** The same route's acceptance, naming the backend that will run it. */
475
- pass(backend) {
476
- if (phase !== "requested") return;
477
- phase = "passed";
478
- if (props) props.backend = backend;
479
- emit("motion:preflight_passed");
480
- },
481
- start() {
482
- if (phase !== "passed") return;
483
- phase = "started";
484
- startedAt = clock();
485
- emit("motion:job_started");
486
- },
487
- succeed() {
488
- if (phase !== "started") return;
489
- phase = "succeeded";
490
- emit("motion:job_succeeded", { duration_bucket: bucketMs(clock() - startedAt) });
491
- },
492
- fail(error, fallbackCode) {
493
- if (phase !== "started") return;
494
- phase = "failed";
495
- emit("motion:job_failed", { duration_bucket: bucketMs(clock() - startedAt), error_code: motionFailureCode(error, fallbackCode) });
496
- },
497
- apply() {
498
- if (phase !== "succeeded") return;
499
- phase = "applied";
500
- emit("motion:result_applied");
501
- },
502
- };
503
- }
504
-
505
360
  export const FEATURE_USAGE_NAMES = Object.freeze([...FEATURE_NAMES]);
506
361
 
507
362
  export function bucketCount(value) {
package/src/app-stage.jsx CHANGED
@@ -62,7 +62,7 @@ export {
62
62
 
63
63
  export * from "./stage-attach-frames.js";
64
64
 
65
- export { Character } from "./stage-characters.jsx";
65
+ export { Character, PlaybackCharacter } from "./stage-characters.jsx";
66
66
 
67
67
  export {
68
68
  ShotRig,
@@ -49,9 +49,6 @@ const OBJECT_GLYPHS = {
49
49
  cylinder: "M5 7c0-1.38 3.13-2.5 7-2.5s7 1.12 7 2.5-3.13 2.5-7 2.5S5 8.38 5 7z M5 7v10c0 1.38 3.13 2.5 7 2.5s7-1.12 7-2.5V7",
50
50
  cone: "M12 3.5 5 17 M12 3.5 19 17 M5 17c0 1.38 3.13 2.5 7 2.5s7-1.12 7-2.5",
51
51
  plane: "M12 7.5 21 12l-9 4.5L3 12z",
52
- chair: "M7 3.5h10v8.5H7z M5.5 12h13v3h-13z M7 15v5.5 M17 15v5.5",
53
- car: "M3 15.5v-3l2.5-.5 2.5-4h8l3 4 2 .5v3z M6 15.5a1.75 1.75 0 1 0 3.5 0 M14.5 15.5a1.75 1.75 0 1 0 3.5 0 M8 12h10",
54
- "small-plane": "M12 3v18 M3 11.5l9-2 9 2v2l-9-1-9 1z M8.5 19.5 12 18.5l3.5 1",
55
52
  };
56
53
 
57
54
  const STORAGE_FOLDER = "__storage";
@@ -368,12 +365,11 @@ function matchesQuery(label, query) {
368
365
  return !query || String(label).toLowerCase().includes(query);
369
366
  }
370
367
 
371
- /** Folder ids in display order. Basic Shapes and Sets split the object
372
- * catalogue by its own groups; Props holds what the user imported. */
368
+ /** Folder ids in display order. Basic Shapes holds the object catalogue's
369
+ * primitives; Props holds what the user imported. */
373
370
  const FOLDERS = [
374
371
  { id: "basic", label: () => ko("Basic Shapes", "기본 도형") },
375
372
  { id: "characters", label: () => ko("Characters", "인물") },
376
- { id: "sets", label: () => ko("Sets", "세트") },
377
373
  { id: "props", label: () => ko("Props", "소품") },
378
374
  { id: "cameras", label: () => ko("Cameras", "카메라") },
379
375
  { id: "motions", label: () => ko("Motions", "모션") },
@@ -389,7 +385,7 @@ function catalogueTile(entry, props) {
389
385
  key={key}
390
386
  assetKey={key}
391
387
  label={label}
392
- type={entry.group === "Primitives" ? ko("Shape", "도형") : ko("Set piece", "세트 소품")}
388
+ type={ko("Shape", "도형")}
393
389
  title={ko(`Drag ${entry.label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
394
390
  preview={<ObjectPreview kind={entry.kind} color={entry.color} />}
395
391
  selected={props.selectedKey === key}
@@ -408,10 +404,9 @@ export function FolderGrid({ folder, query, ...props }) {
408
404
  const { imageAssetIds, meshAssetIds, shots = [], takeVersions = [], poses = [], onShotOpen, onTakeOpen, selectedKey, onSelect } = props;
409
405
  let tiles = [];
410
406
  let empty = null;
411
- if (folder === "basic" || folder === "sets") {
412
- const group = folder === "basic" ? "Primitives" : "Set pieces";
407
+ if (folder === "basic") {
413
408
  tiles = OBJECT_LIBRARY
414
- .filter((entry) => entry.group === group && matchesQuery(displayObjectLabel(entry.label), query))
409
+ .filter((entry) => entry.group === "Primitives" && matchesQuery(displayObjectLabel(entry.label), query))
415
410
  .map((entry) => catalogueTile(entry, props));
416
411
  } else if (folder === "characters") {
417
412
  tiles = CHARACTER_ASSETS.filter((asset) => matchesQuery(asset.label, query)).map((asset) => {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The viewport's auto-colour material: a solid-view response for
3
+ * auto-coloured surfaces. A neutral studio shade lets the derived hue reach
4
+ * the eye unmultiplied; under our warm key light a plain standard material
5
+ * turns every hue olive. Full roughness kills the specular sheen and a
6
+ * self-light of the same hue lifts the shadow side.
7
+ */
8
+ export function autoFlat(hex) {
9
+ return { color: hex, roughness: 1, metalness: 0, emissive: hex, emissiveIntensity: 0.4 };
10
+ }
@@ -0,0 +1,69 @@
1
+ // The document targets a command writes, known before it runs, for tasks that edit one scene at once (parallel Studio
2
+ // tasks, P1). A key names one target: object:<id> (a group carries its subtree), character:<id> (its placement, its
3
+ // take, its IK), shot:<id> (its camera, rail and keys), or one of the singletons stage (light, environment, lens),
4
+ // timeline (frame count, shot order and ranges) and scene (documents, the cast library, a replace of a whole kind).
5
+ // timeline and scene are exclusive: no other task runs beside them. A command that only creates (object.add, a kit,
6
+ // character.add) needs no key: what it makes joins its task. A command this table does not know writes scene, so a
7
+ // new command is safe until it is named here.
8
+ const EXCLUSIVE_KEYS = new Set(['timeline', 'scene']);
9
+ const KEY = /^(?:(?:object|character|shot):[A-Za-z0-9][A-Za-z0-9._:-]*|stage|timeline|scene)$/;
10
+ export const isScopeKey = key => typeof key === 'string' && key.length <= 140 && KEY.test(key);
11
+
12
+ const SCENE = ['cast.replace', 'cast.setCustomPoses', 'cast.showExtras', 'cast.removePose', 'objects.replace', 'shot.replace', 'ai.prepareShot'];
13
+ const TIMELINE = ['shot.create', 'shot.createStill', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.reorder', 'shot.setRange', 'shot.setTimeline'];
14
+ const CREATE = ['object.add', 'object.createKit', 'asset.import', 'character.add'];
15
+ const CHARACTER = /^(character\.|characters?\b|cast\.(savePose|setLayer)$|ik\.|motion\.)/;
16
+
17
+ /** object:<id> and every object under it. */
18
+ function objectKeys(ids, state) {
19
+ const rows = state?.objects ?? [], out = new Set();
20
+ const visit = id => { if (!id || out.has(`object:${id}`)) return; out.add(`object:${id}`); for (const row of rows) if (row.parent === id) visit(row.id); };
21
+ for (const id of ids) visit(id);
22
+ return [...out];
23
+ }
24
+ const arrangeKeys = (ops, state) => ops.flatMap(op => op.op === 'create' ? [] : op.op === 'group' ? objectKeys([op.parentId, ...op.childIds], state)
25
+ : op.op === 'ungroup' ? objectKeys(op.childIds, state) : objectKeys([op.id], state));
26
+
27
+ /**
28
+ * The keys command `id` with validated `args` writes against state `state` (the bus's read), or [] when it writes
29
+ * none (a read, a view change, a pure creation). `entry` is its registry entry (kind, readOnly).
30
+ */
31
+ export function commandKeys(id, args = {}, state = {}, entry = {}) {
32
+ if (entry.kind === 'document' || /^(scene|project)\./.test(id) || id === 'load_scenes' || SCENE.includes(id)) return ['scene'];
33
+ if (entry.readOnly || entry.kind === 'transient' || /^(view|timeline)\./.test(id) || ['motion.qa', 'motion.sample', 'motion.describe', 'motion.report', 'sculpt.compare', 'export.shotVideo'].includes(id)) return [];
34
+ if (CREATE.includes(id)) return [];
35
+ if (id === 'objects.batch') return [...new Set(args.ops.flatMap(op => commandKeys(op.name, op.args, state)))];
36
+ if (id === 'objects.arrange') return [...new Set(arrangeKeys(args.ops, state))];
37
+ if (id === 'characters.arrange') return args.ops.flatMap(op => op.op === 'create' ? [] : [`character:${op.characterId}`]);
38
+ if (id.startsWith('stage.') || id === 'shot.setLens') return ['stage'];
39
+ if (TIMELINE.includes(id)) return ['timeline'];
40
+ if (id.startsWith('shot.')) {
41
+ const shot = args.shotId ?? args.id ?? state.selectedShotId;
42
+ return shot ? [`shot:${shot}`] : ['timeline'];
43
+ }
44
+ if (id === 'object.group') return objectKeys([args.parent, ...args.children], state);
45
+ if (id === 'object.ungroup') return objectKeys(args.children, state);
46
+ if (id === 'object.remove') return objectKeys(args.ids, state);
47
+ if (id === 'object.attach' || id === 'motion.keyObject') return [`character:${args.characterId}`, ...objectKeys([args.objectId], state)];
48
+ if (id.startsWith('object.')) return objectKeys([args.id ?? args.objectId], state);
49
+ if (CHARACTER.test(id) && (args.characterId ?? args.id)) return [`character:${args.characterId ?? args.id}`];
50
+ return ['scene'];
51
+ }
52
+
53
+ /** The key an id a receipt names resolves to in state, or null (the scene's own id, a removed target). */
54
+ export function keyOfId(id, state = {}) {
55
+ if ((state.objects ?? []).some(row => row.id === id)) return `object:${id}`;
56
+ if ((state.characters ?? []).some(row => row.id === id)) return `character:${id}`;
57
+ if ((state.shots ?? []).some(row => row.id === id)) return `shot:${id}`;
58
+ return null;
59
+ }
60
+
61
+ /** Why `keys` cannot join task `taskId` beside the other open tasks, or null. */
62
+ export function scopeConflict(taskId, keys, tasks) {
63
+ for (const other of tasks.values()) {
64
+ if (other.taskId === taskId) continue;
65
+ const held = keys.find(key => other.keys.has(key) || EXCLUSIVE_KEYS.has(key) || [...other.keys].some(k => EXCLUSIVE_KEYS.has(k)));
66
+ if (held) return `${held} is held by task ${other.taskId}${other.title ? ` (${other.title})` : ''}`;
67
+ }
68
+ return null;
69
+ }
@@ -0,0 +1,48 @@
1
+ // What a task's scope keys are in the editor's document stores, for the bus's tasks (bus-tasks.js): a snapshot of a
2
+ // key is its rows (an object's row; a character's cast row and motion layer; a shot's row) or, for stage and timeline,
3
+ // the whole slices; restoring puts every row back where it stood (or removes a row the task created) in one composed
4
+ // entry, so undo, history and saves see an ordinary edit. scene is not restorable: documents have no undo.
5
+ import { StudioProtocolError } from './studio-agent-protocol.js';
6
+
7
+ const ROWS = { object: [['objects', null]], character: [['cast', 'characters'], ['motion', null]], shot: [['shot', 'shots']] };
8
+ const WHOLE = { stage: ['stage'], timeline: ['shot', 'motion'] };
9
+ const split = key => { const at = key.indexOf(':'); return at < 0 ? [key] : [key.slice(0, at), key.slice(at + 1)]; };
10
+
11
+ function put(slice, part) {
12
+ if ('whole' in part) return part.whole;
13
+ const rows = (part.path ? slice[part.path] : slice).filter(row => row.id !== part.id);
14
+ if (part.row) rows.splice(Math.min(part.index, rows.length), 0, part.row);
15
+ return part.path ? { ...slice, [part.path]: rows } : rows;
16
+ }
17
+
18
+ /** snapshotTarget(key) and restoreTargets([{ key, value }]) over the app's store domains (ports.storeDomain, ports.beginAction). */
19
+ export function taskTargetPorts(ports) {
20
+ const store = domain => ports.storeDomain?.(domain)?.documentStore;
21
+ const rowParts = (kind, id, read) => ROWS[kind].filter(([domain]) => store(domain)).map(([domain, path]) => {
22
+ const slice = store(domain).read(domain), rows = (path ? slice?.[path] : slice) ?? [];
23
+ const index = read ? rows.findIndex(row => row.id === id) : -1;
24
+ return { domain, path, id, index, row: index < 0 ? undefined : rows[index] };
25
+ });
26
+ return {
27
+ snapshotTarget(key) {
28
+ const [kind, id] = split(key);
29
+ if (WHOLE[kind]) return WHOLE[kind].filter(store).map(domain => ({ domain, whole: store(domain).read(domain) }));
30
+ return ROWS[kind] ? rowParts(kind, id, true) : null;
31
+ },
32
+ restoreTargets(values) {
33
+ const parts = values.flatMap(({ key, value }) => {
34
+ if (value === null) throw new StudioProtocolError('CAPABILITY_MISSING', `${key} cannot be restored: a document change has no undo.`);
35
+ const [kind, id] = split(key);
36
+ return value ?? rowParts(kind, id, false);
37
+ });
38
+ const domains = [...new Set(parts.map(part => part.domain))];
39
+ if (!domains.length) return { historyEntryId: null };
40
+ const session = ports.beginAction(domains[0], null);
41
+ try {
42
+ for (const domain of domains.slice(1)) session.touch(domain);
43
+ session.run(() => { for (const part of parts) store(part.domain).write(part.domain, slice => put(slice, part)); });
44
+ return session.commit();
45
+ } catch (error) { session.cancel(); throw error; }
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,181 @@
1
+ // Tasks: several agents editing one scene at once (parallel Studio tasks, P1; plan in docs). A task holds a scope of
2
+ // keys (bus-task-scopes.js) instead of the whole-scene turn lock: its commands write only inside it, each command
3
+ // commits as its own short entry (the per-command write lock is the document's own composition), and admission reads
4
+ // the scope's target tokens, not the scene revision, so another task's edit elsewhere never makes it stale. Before a
5
+ // key's first write the task keeps its pre-image (ports.snapshotTarget); cancel and undo-task put the pre-images back
6
+ // as one new entry (ports.restoreTargets), which touches no other task's keys. A mark and rollback (a script's start)
7
+ // and undo_edit of the task's newest edit restore the same way. The user's own edits are never refused: the task's
8
+ // next command on a target the user changed answers STALE_TARGET once. Ctrl+Z stops at a running task's entry.
9
+ // A single agent turn (agent.turn.*) is untouched: tasks and a turn never run together.
10
+ import { StudioProtocolError, validateReceipt } from './studio-agent-protocol.js';
11
+ import { commandKeys, keyOfId, scopeConflict, isScopeKey } from './bus-task-scopes.js';
12
+
13
+ const fail = (code, message) => { throw new StudioProtocolError(code, message); };
14
+ const VERBS = ['begin', 'requestScope', 'mark', 'rollback', 'finish', 'cancel', 'undo'];
15
+ const FINISHED_KEPT = 32;
16
+ const listOf = keys => [...keys].slice(0, 6).join(', ') + (keys.length > 6 ? ` and ${keys.length - 6} more` : '');
17
+
18
+ export function createBusTasks({ ports, emit }) {
19
+ const open = new Map(), finished = new Map(), entryTask = new Map();
20
+ const tokenOf = key => {
21
+ const [kind, id] = key.split(/:(.*)/s);
22
+ if (id !== undefined) return ports.readTarget?.(id) ?? 'absent';
23
+ const state = ports.read();
24
+ return kind === 'scene' ? `r${state.revision}` : `${kind}:${state.domainRevisions?.[kind === 'timeline' ? 'shot' : kind] ?? 0}`;
25
+ };
26
+ const snapshot = key => ({ key, value: ports.snapshotTarget ? ports.snapshotTarget(key) : fail('CAPABILITY_MISSING', 'This editor cannot snapshot task targets.') });
27
+ // A scene key has no restorable value (documents have no undo): it is left as it is and said so.
28
+ function restore(task, values, action) {
29
+ task.skipped = values.filter(v => v.value === null).map(v => v.key);
30
+ values = values.filter(v => v.value !== null);
31
+ if (!values.length) return null;
32
+ if (!ports.restoreTargets) fail('CAPABILITY_MISSING', 'This editor cannot restore task targets.');
33
+ const { historyEntryId = null } = ports.restoreTargets(values) ?? {};
34
+ if (historyEntryId) entryTask.set(historyEntryId, task.taskId);
35
+ task.restored = [...new Set(values.map(({ key }) => key.includes(':') ? key.slice(key.indexOf(':') + 1) : ports.read().host.sceneId))];
36
+ for (const { key } of values) task.tokens.set(key, tokenOf(key));
37
+ emit?.({ type: `task.${action}`, taskId: task.taskId, keys: values.map(v => v.key) });
38
+ return historyEntryId;
39
+ }
40
+ function grant(task, keys) {
41
+ const bad = keys.find(key => !isScopeKey(key));
42
+ if (bad !== undefined) fail('INVALID_ARGUMENT', `"${bad}" is not a scope key: object:<id>, character:<id>, shot:<id>, stage, timeline or scene.`);
43
+ const conflict = scopeConflict(task.taskId, keys, open);
44
+ if (conflict) fail('SCOPE_BUSY', `${conflict}; wait for it, or ask for what is free.`);
45
+ for (const key of keys) if (!task.keys.has(key)) { task.keys.add(key); task.tokens.set(key, tokenOf(key)); }
46
+ }
47
+ function receipt(id, task, request, before, { historyEntryId = null, affectedIds = [], output, warnings = [] } = {}) {
48
+ const after = ports.read();
49
+ if (task.skipped?.length) warnings = [...warnings, { code: 'SCOPE_NOT_RESTORED', message: `Left as they are (a document change has no undo): ${listOf(task.skipped)}.` }];
50
+ task.skipped = null;
51
+ return validateReceipt({ ok: true, status: 'completed', kind: 'transaction', commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
52
+ action: id, txId: task.taskId, authored: Boolean(historyEntryId), revision: { before: before.revision, after: after.revision },
53
+ affectedIds: historyEntryId && !affectedIds.length ? task.restored : affectedIds, delta: [],
54
+ checks: { coverage: 'task-scope' }, warnings, undo: historyEntryId ? { historyEntryId, entries: 1, canUndoDirect: false } : null,
55
+ output: { scope: [...task.keys], ...output } });
56
+ }
57
+ const ownerOf = (request, taskId) => { if (request.origin !== 'agent' || request.taskId !== taskId) fail('CAPABILITY_MISSING', 'Only the owning task can control it.'); };
58
+ return {
59
+ get size() { return open.size; },
60
+ controls: (object, identifier) => Object.fromEntries(VERBS.map(verb => [`task.${verb}`,
61
+ verb === 'begin' || verb === 'requestScope'
62
+ ? { ...object({ taskId: identifier, scope: { type: 'array', items: { type: 'string' }, maxItems: 64 } }), properties: { taskId: identifier, scope: { type: 'array', items: { type: 'string' }, maxItems: 64 }, reads: { type: 'array', items: { type: 'string' }, maxItems: 64 }, title: { type: 'string', maxLength: 120 } } }
63
+ : object({ taskId: identifier })])),
64
+ /** The open task a request names (null without one); a request naming a closed task is refused. */
65
+ of(request, id) {
66
+ if (!request.taskId) return null;
67
+ const task = open.get(request.taskId);
68
+ if (!task && !id.startsWith('task.')) fail('STALE_TARGET', `Task ${request.taskId} is not open.`);
69
+ return task ?? null;
70
+ },
71
+ /** task.* controls. `busy` says why no task may begin (an agent turn or a command transaction is open). */
72
+ control(id, args, request, before, busy) {
73
+ if (id === 'task.undo') {
74
+ const task = finished.get(args.taskId) ?? fail('UNDO_EXPIRED', `Task ${args.taskId} is not a finished task this editor still holds.`);
75
+ // A key moved when its token did and its content did too: a later task undone first puts the same content back
76
+ // under a new token, and that is not a change.
77
+ const moved = [...task.final].filter(([key, { token, value }]) => tokenOf(key) !== token && JSON.stringify(snapshot(key).value) !== value).map(([key]) => key);
78
+ if (moved.length) fail('UNDO_CONFLICT', `Changed since the task finished: ${listOf(moved)}; undo those first or edit by hand.`);
79
+ const historyEntryId = restore(task, [...task.pre.values()], 'undone');
80
+ finished.delete(args.taskId);
81
+ return receipt(id, task, request, before, { historyEntryId, affectedIds: task.affectedIds });
82
+ }
83
+ ownerOf(request, args.taskId);
84
+ if (id === 'task.begin') {
85
+ if (busy) fail('TARGET_BUSY', busy);
86
+ if (open.has(args.taskId) || finished.has(args.taskId)) fail('INVALID_ARGUMENT', `Task ${args.taskId} already exists.`);
87
+ const task = { taskId: args.taskId, title: args.title ?? '', keys: new Set(), reads: new Set(args.reads ?? []), tokens: new Map(), pre: new Map(), edits: [], notices: [], affectedIds: [] };
88
+ grant(task, args.scope);
89
+ open.set(task.taskId, task);
90
+ emit?.({ type: 'task.begun', taskId: task.taskId, scope: [...task.keys] });
91
+ return receipt(id, task, request, before);
92
+ }
93
+ const task = open.get(args.taskId) ?? fail('STALE_TARGET', `Task ${args.taskId} is not open.`);
94
+ if (id === 'task.requestScope') { grant(task, args.scope); emit?.({ type: 'task.scope', taskId: task.taskId, scope: [...task.keys] }); return receipt(id, task, request, before); }
95
+ if (id === 'task.mark') { task.mark = { values: [...task.keys].map(snapshot), keys: new Set(task.keys) }; return receipt(id, task, request, before); }
96
+ if (id === 'task.rollback') {
97
+ const mark = task.mark ?? fail('STALE_TARGET', 'The task has no mark to roll back to.');
98
+ task.mark = null;
99
+ const after = [...task.keys].filter(key => !mark.keys.has(key)).map(key => task.pre.get(key) ?? snapshot(key));
100
+ const historyEntryId = restore(task, [...mark.values, ...after], 'rolledBack');
101
+ task.edits = task.edits.filter(edit => edit.mark !== mark);
102
+ return receipt(id, task, request, before, { historyEntryId });
103
+ }
104
+ open.delete(task.taskId);
105
+ if (id === 'task.cancel') {
106
+ const historyEntryId = restore(task, [...task.pre.values()], 'cancelled');
107
+ return receipt(id, task, request, before, { historyEntryId, affectedIds: task.affectedIds });
108
+ }
109
+ task.final = new Map([...task.pre.keys()].map(key => [key, { token: tokenOf(key), value: JSON.stringify(snapshot(key).value) }]));
110
+ finished.set(task.taskId, task);
111
+ while (finished.size > FINISHED_KEPT) finished.delete(finished.keys().next().value);
112
+ emit?.({ type: 'task.finished', taskId: task.taskId });
113
+ return receipt(id, task, request, before, { affectedIds: task.affectedIds, output: { edits: task.edits.length } });
114
+ },
115
+ /**
116
+ * Admits a command: a task writes only inside its scope, on targets nobody else changed since it last looked; a
117
+ * wire caller outside any task may not write a running task's keys; the UI always may. Answers the point a
118
+ * task's edit restores to (undo_edit of it), after keeping each key's first pre-image.
119
+ */
120
+ admit(task, entry, args, request, before) {
121
+ if (!task && !open.size) return null;
122
+ const keys = entry?.id ? commandKeys(entry.id, args, before, entry) : [];
123
+ // The user's own edit is never refused; a running task whose scope it touches hears of it (the panel's card).
124
+ if (!task && request.origin === 'ui') { for (const other of open.values()) { const mine = keys.filter(key => other.keys.has(key)); if (mine.length) emit?.({ type: 'task.userEdit', taskId: other.taskId, keys: mine }); } return null; }
125
+ if (!task) {
126
+ const held = keys.find(key => scopeConflict(null, [key], open));
127
+ if (held) fail('SCOPE_BUSY', `${scopeConflict(null, [held], open)}; this command waits for that task.`);
128
+ return null;
129
+ }
130
+ const outside = keys.filter(key => !task.keys.has(key));
131
+ if (outside.length) fail('SCOPE_DENIED', `Outside this task's scope: ${listOf(outside)}. Ask for it with task.requestScope, or leave it to the task that holds it.`);
132
+ const moved = keys.filter(key => task.tokens.has(key) && tokenOf(key) !== task.tokens.get(key));
133
+ if (moved.length) {
134
+ for (const key of moved) task.tokens.set(key, tokenOf(key));
135
+ fail('STALE_TARGET', `${listOf(moved)} changed outside this task (the user, or an undo) since it last looked; read it again, then retry.`);
136
+ }
137
+ for (const key of keys) if (!task.pre.has(key)) task.pre.set(key, snapshot(key));
138
+ return { task, keys, values: keys.map(snapshot), mark: task.mark };
139
+ },
140
+ /** Files a task's receipt: ids it created join its scope, it may be undone newest first, readers hear of it. */
141
+ record(point, recorded) {
142
+ if (!point || !recorded?.ok) return recorded;
143
+ const { task } = point, state = ports.read(), warnings = [];
144
+ let grew = false;
145
+ for (const id of recorded.affectedIds ?? []) {
146
+ const key = keyOfId(id, state);
147
+ if (!key || task.keys.has(key)) continue;
148
+ if (scopeConflict(task.taskId, [key], open)) warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` });
149
+ else { task.keys.add(key); grew = true; if (!point.keys.includes(key)) { task.pre.set(key, { key, value: undefined }); point.values.push({ key, value: undefined }); } }
150
+ }
151
+ if (grew) emit?.({ type: 'task.scope', taskId: task.taskId, scope: [...task.keys] });
152
+ for (const key of task.keys) task.tokens.set(key, tokenOf(key));
153
+ if (recorded.authored) {
154
+ task.edits.push({ receiptId: recorded.receiptId, values: point.values, keys: point.keys, mark: point.mark });
155
+ task.affectedIds = [...new Set([...task.affectedIds, ...recorded.affectedIds])];
156
+ if (recorded.undo?.historyEntryId) entryTask.set(recorded.undo.historyEntryId, task.taskId);
157
+ for (const other of open.values()) for (const key of point.keys) if (other !== task && other.reads.has(key)) {
158
+ other.notices.push({ code: 'DEPENDENCY_CHANGED', message: `${key}, which this task reads, was changed by task ${task.taskId}.` });
159
+ emit?.({ type: 'task.dependencyChanged', taskId: other.taskId, key, by: task.taskId });
160
+ }
161
+ }
162
+ const heard = task.notices.splice(0);
163
+ return heard.length || warnings.length ? { ...recorded, warnings: [...recorded.warnings, ...warnings, ...heard].slice(0, 12) } : recorded;
164
+ },
165
+ /** History traversal: undo_edit of a task's newest edit inside it; the UI's undo stops at a running task's entry. */
166
+ traversal(id, args, request, before, frontier) {
167
+ const task = request.taskId ? open.get(request.taskId) : null;
168
+ if (task && id === 'edit.undo') {
169
+ const at = task.edits.findIndex(edit => edit.receiptId === args.receiptId);
170
+ if (at < 0) fail('TARGET_BUSY', `Inside a task, undo_edit takes the receiptId of the task's own newest edit${task.edits.length ? ` (now ${task.edits.at(-1).receiptId})` : ''}.`);
171
+ if (at < task.edits.length - 1) fail('UNDO_CONFLICT', `Later edits of this task come after it; undo them first, newest first: ${task.edits.slice(at + 1).reverse().map(edit => edit.receiptId).join(', ')}.`);
172
+ const edit = task.edits.pop(), historyEntryId = restore(task, edit.values, 'editUndone');
173
+ return receipt(id, task, request, before, { historyEntryId, output: { undoneReceiptId: edit.receiptId } });
174
+ }
175
+ const owner = open.get(entryTask.get(frontier));
176
+ if (!task && owner) fail('TARGET_BUSY', `The last edit belongs to running task ${owner.taskId}${owner.title ? ` (${owner.title})` : ''}; cancel or undo it from that task.`);
177
+ return null;
178
+ },
179
+ dispose() { open.clear(); finished.clear(); entryTask.clear(); },
180
+ };
181
+ }