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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,198 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.2
4
+
5
+ The first-run audit release: `npx cozyclay` is exercised end to end as a new
6
+ user, and the things that broke on that path are fixed. 2.0.1 (the camera
7
+ tutorial refresh and light-mode default, #586) was tagged but never reached
8
+ npm; this release carries it.
9
+
10
+ Agent and package
11
+
12
+ - The Studio Agent panel in the npm package answered every turn with
13
+ "502 — agent unavailable": the sidecar's Studio tools imported `three`
14
+ (a devDependency the package does not ship) through `src/scenes.js`. The
15
+ timeline clock moved to its own module and a test now walks every import
16
+ reachable from `bin/` and fails on any chain to `three`. (#591)
17
+ - A CLIProxy key in the environment no longer leaves the panel on the ChatGPT
18
+ sign-in wall; readiness counts every key-authenticated provider. (#591)
19
+ - `CCLAY_KIMODO_HOST=… npx cozyclay` died at start with
20
+ `Cannot find package 'three'`: the motion bridge now runs from the staged
21
+ MCP runtime (which carries `three`, `tools/` and `dist/models`), and the
22
+ package isolation test keeps it that way. (#591)
23
+ - A live-hub port already taken by another CozyClay is reported on the
24
+ launcher's terminal and named in the pane's error instead of
25
+ "Studio execution is not installed"; agent handler failures are logged
26
+ before the 502; a session id with no record no longer shows
27
+ "Previous conversation could not be restored". (#591)
28
+
29
+ Studio
30
+
31
+ - Create on the start screen opens the selected sample scene under the typed
32
+ name instead of an empty stage named after it; starting over with unsaved
33
+ work asks first. (#591)
34
+ - Reloading a project created from the start screen brought the previous
35
+ session's walking take back onto the character, with "Unsaved changes":
36
+ the hosted-demo seed re-fired on every mount while the bridge probe was
37
+ down. It now skips a saved project (a `?scene=` launch still seeds), and a
38
+ reload opens on the clean checkpoint. (#591)
39
+ - With a take installed, the gizmo and the Transform panel's Position and
40
+ Rotation move the character again; the take keeps animating relative to
41
+ the new placement. (#591)
42
+ - The shot card's hover actions sit above the card, so clicking a short
43
+ shot's name selects it instead of hitting Delete. Draw rail is disabled
44
+ with a reason when no shot is selected. (#591)
45
+
46
+ Tests
47
+
48
+ - The start-screen and project-menu browser suites run against the v2
49
+ shell again, gain the sample-card Create regression, and join the CI
50
+ browser matrix as observation suites; new regressions cover the package
51
+ import graph, CLIProxy readiness, the stage transform and the reload
52
+ session. (#591)
53
+
54
+ ## 2.0.1
55
+
56
+ - The camera tutorial follows the v2 shell and new installs default to the
57
+ light theme. (#586)
58
+
59
+ ## 2.0.0
60
+
61
+ CozyClay 2.0 is the v2 editor: a floating-glass shell with light and dark
62
+ themes, one four-mode Studio (Stage, Pose, Camera, Motion on keys 1-4), and
63
+ the v2 Outliner, Sequencer, start screen, Details panels and Preferences.
64
+ Under it, every document edit, whether it comes from the UI, the Agent panel,
65
+ MCP or the CLI, now runs through one command bus on a document store with one
66
+ undo history, so anything the editor can do is callable by an agent without
67
+ separate wiring. The Studio Agent panel in the npm package works again
68
+ (#576). **No MCP tool or CLI command was removed**: legacy MCP tools are
69
+ aliases of bus commands and answer the editor's receipts.
70
+
71
+ Studio
72
+
73
+ - Floating-glass shell: the viewport fills the window and the top bar, both
74
+ columns, the dock and the status strip float over it. Light and dark
75
+ themes, and the stage follows the theme live. The dock runs Animation |
76
+ Assets tabs sized to the Sequencer's lanes, with the shot preview docked at
77
+ its right end; the standing status row is gone and live export status
78
+ floats in the viewport. (#570, #572, #519)
79
+ - One four-mode switch, Stage, Pose, Camera and Motion, on keys 1-4. Pose
80
+ mode is the single way into IK (W/E/R pick ik, trail and pin there); the
81
+ timeline IK toggle and the Rig Control IK buttons are gone, and leaving
82
+ Pose ends IK. (#521)
83
+ - v2 Outliner: 36 px header with the item count, search, typed rows, amber
84
+ selection; right-click Create replaces the toolbar's + Add object. (#525)
85
+ - v2 Sequencer: header, readout and actions, a 190 px track tree, clip
86
+ fills, amber playhead, keys and pin bands, scrollable camera, rail and
87
+ crane rows. (#528)
88
+ - v2 start screen: project nav, template categories, recent rows, the
89
+ browser project store and a project preview; File › New opens the same
90
+ screen. (#531, #551)
91
+ - v2 Details panels and primitives: pose tools with an auto-fix preview,
92
+ motion details with takes, the viewport toolbar and overlays, the top bar,
93
+ menus and status bar, the content browser and log, and the Preferences
94
+ dialog. (#522, #523, #524, #526, #527, #529, #530, #532)
95
+ - Generate actions have one home: the top-bar Generate Motion button runs
96
+ every prompt block when any block has text. Copy follows v2: "ARDY" leaves
97
+ the user-facing text, pose toasts say "Pose mode", path endpoints read
98
+ PATH START / PATH END, the first-run guide says Outliner. (#550, #551)
99
+ - Top-View props recede so the camera, cast and rail read first. (#566)
100
+ - When WebGL is unavailable the stage says so instead of staying blank. (#518)
101
+ - 194 dead CSS rules are gone and the v2 information architecture is
102
+ recorded in `docs/studio-ui-ia.md`; Details, Outliner and top-bar props
103
+ that were never read are dropped. (#533, #552, #556, #558, #560, #562)
104
+ - Korean UI: 56 reversed `ko()` argument pairs in the motion card are fixed
105
+ (#505, by @romanr), as are the VideoCapturePanel quality-gate messages
106
+ (#565).
107
+
108
+ IK and motion
109
+
110
+ - IK and motion trails on the split architecture: delta IK drag baking over
111
+ the raw clip, per-key blend persistence, a remembered IK camera entry,
112
+ trail picking against the rendered rig, limb, head and root trail edits
113
+ with conflict warnings. (#511)
114
+ - Range pin with an exact hinge solve and body reach compensation, carried
115
+ through persistence, undo/redo, timeline bands and preview markers. (#515)
116
+ - Fit to platforms with Fit / Remove controls and a stable knee bend side,
117
+ verified over 784 cases with no reverse bends. (#517)
118
+ - One motion generation pipeline on the job contract: `motion.generate`
119
+ serves the UI, the Agent and MCP alike, with concurrent-edit parity (a
120
+ stale job publishes nothing, an unrelated object edit still installs),
121
+ Stop and cancel, reconnect, and receipt undo. The agent-only motion
122
+ pipeline is deleted. (#444, #452)
123
+ - Image-to-video motion is named `i2v` in code and "AI motion" in the UI;
124
+ "fal" names only the provider adapter. Command ids, analytics values and
125
+ persisted values are unchanged. (#509)
126
+
127
+ Command bus and document store
128
+
129
+ - Every document mutation runs through one command bus: wire transactions,
130
+ a scoped job fence, receipts, refusals and exposure. Action registrations,
131
+ the agent binding and the domain writers moved out of App.jsx. (#435,
132
+ #437, #459, #460, #477)
133
+ - A document store owns stage, objects, shots, cast, motion and IK, and
134
+ project and scenes, each routed through the bus, with collection element
135
+ kinds and cross-domain undo order. (#449, #447, #445, #441, #442, #448,
136
+ #461, #480)
137
+ - One store-owned undo history; the per-domain history owners are gone.
138
+ (#494) Duplicate agent paths are removed: `inspect_studio`, `run_action`
139
+ and `verify_result` are the three tool families, `operate_studio` is an
140
+ alias, and selection, playhead and view changes add no undo entries.
141
+ (#495) The writer baseline is zero and parity is complete: all 62
142
+ document-mutating UI handlers reach `run`, enforced by ratchet tests and
143
+ a generated parity matrix. (#436, #496)
144
+
145
+ Agent
146
+
147
+ - The Studio agent pane reaches the whole Studio through the action
148
+ registry the UI dispatches through, and every bus command is discoverable
149
+ and callable through agent `run_action`, MCP `studio_commands` /
150
+ `studio_run` and `cclay live commands` / `cclay live run` with no
151
+ agent-specific wiring. (#423, #438)
152
+ - Motion failures are visible and routable, `verify_result` reports
153
+ honestly, and a failed attempt no longer uses up the message's one
154
+ generation. (#426)
155
+ - Minimal Studio Agent panel: an icon-only head, the account under the ⋯
156
+ menu, one-line notices, an empty state with suggestion chips; it sits
157
+ under Details in the right column instead of replacing it. (#574, #570)
158
+ - In the npm package the Studio Agent panel loads its live tools from the
159
+ staged MCP runtime that `cclay mcp` installs once per version, so the live
160
+ hub starts and the pane works; `mcp/mesh-file.mjs` ships with the package
161
+ and `cclay mcp` starts again. A runtime that cannot be installed is
162
+ reported on the launcher's terminal. (#576)
163
+
164
+ MCP
165
+
166
+ - Legacy tools are thin aliases of bus commands and return the editor's JSON
167
+ receipts: revision before and after, retained undo and refusal codes.
168
+ `apply_batch` maps to `objects.batch` and keeps `atomic`, `stopOnError`
169
+ and one undo entry; replacing scene ids needs a Studio-issued confirmation
170
+ token. (#446)
171
+ - `initialize` waits the SDK's default 60 s. (#502)
172
+
173
+ Analytics
174
+
175
+ - Builder telemetry: pose edits, i2v motion A→B, `$exception`, a device
176
+ profile (GPU and frame rate), the install source from
177
+ `npx cozyclay --via`, use case and update status. (#466)
178
+
179
+ Tools
180
+
181
+ - A mocap research bench under `tools/bench/` and `tools/gt-render/`:
182
+ ground-truth part-colour renders with camera truths (#428), a GVHMR
183
+ extraction bench (#430), a scorer for pose, trajectory, endpoints, overlap
184
+ and contact (#431), 3D fit stages F0-F5 (#432), a cube-contact scenario
185
+ (#454), the observation-driven GVHMR tuning ladder G0-G5 (#462, #488,
186
+ #492) and the known-character tracker T1 (#500).
187
+
188
+ Housekeeping
189
+
190
+ - The camera-rail browser test waits for the delete toast, which exposed and
191
+ fixed a stale-toast dismissal bug (#470, #483); the lifecycle test waits
192
+ for the grandchild to disappear (#479).
193
+ - The owner's v2 editor design contract is adopted in the docs (#512).
194
+ - Repository traffic is archived to the `metrics` branch twice a week.
195
+
3
196
  ## 1.10.0
4
197
 
5
198
  Props and characters can now be hidden without being deleted, the Studio top
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
- import { basename } from "node:path";
3
+ import { basename, join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
4
5
  import * as defaultAuth from "../codex-auth.mjs";
5
6
  import { publishLiveEndpoint, removeLiveEndpoint } from "../live-endpoint.mjs";
6
7
  import { createCodexClient } from "./codex-client.mjs";
@@ -230,13 +231,30 @@ function defaultClient(auth, requestContext) {
230
231
  const liveHubOwner = () => process.env.COZYCLAY_LIVE_OWNER
231
232
  || (basename(process.argv[1] ?? "") === "dev-full.mjs" ? "dev-full" : "cozyclay");
232
233
 
234
+ /** The two live-tool modules. A source checkout resolves them from the
235
+ * repository, where mcp/ has its own node_modules. The published package
236
+ * ships mcp/ without zod, three or ws, so the launcher passes an `mcpRuntime`
237
+ * whose ensure() installs the staged runtime (bin/mcp-runtime.mjs) once per
238
+ * version and the modules are imported from that root instead (#576). */
239
+ async function liveToolsModules(mcpRuntime) {
240
+ if (!mcpRuntime) return Promise.all([import("../../mcp/tool-handlers.mjs"), import("../../mcp/live-hub.mjs")]);
241
+ const root = await mcpRuntime.ensure();
242
+ const staged = (file) => pathToFileURL(join(root, "mcp", file)).href;
243
+ return Promise.all([import(staged("tool-handlers.mjs")), import(staged("live-hub.mjs"))]);
244
+ }
245
+
233
246
  /** Start the optional registry/live dependencies without making signed-out
234
247
  * startup depend on an MCP dependency install. Failures remain visible on use. */
235
- function liveToolsRuntime() {
236
- return Promise.all([import("../../mcp/tool-handlers.mjs"), import("../../mcp/live-hub.mjs")]).then(async ([registry, { startLiveHub }]) => {
248
+ function liveToolsRuntime(mcpRuntime) {
249
+ return liveToolsModules(mcpRuntime).then(async ([registry, { startLiveHub }]) => {
237
250
  const owner = liveHubOwner();
238
251
  const token = randomBytes(32).toString("hex");
239
- const liveHub = await startLiveHub(Number(process.env.COZYCLAY_LIVE_PORT ?? 5184), { token, owner });
252
+ const livePort = Number(process.env.COZYCLAY_LIVE_PORT ?? 5184);
253
+ const liveHub = await startLiveHub(livePort, { token, owner });
254
+ // EADDRINUSE comes back as null: another CozyClay (or its dev server) owns
255
+ // the port, and the Studio page is built to connect to that port, so the
256
+ // pane would answer every turn with a misleading capability error.
257
+ if (!liveHub) console.error(`cozyclay: live hub port ${livePort} is already in use (another CozyClay or its dev server?); the Agent pane cannot drive this Studio until that port is free.`);
240
258
  registry.setLiveHub(liveHub);
241
259
  const handlers = registry.createToolHandlers().map((tool) => ({
242
260
  ...tool,
@@ -257,14 +275,20 @@ function liveToolsRuntime() {
257
275
  publishLiveEndpoint({ port: liveHub.port, token, owner });
258
276
  liveHub.server?.once("close", () => removeLiveEndpoint(liveHub.port));
259
277
  }
260
- return { liveHub, handlers };
261
- }).catch((error) => ({ error }));
278
+ return { liveHub, handlers, hubPortBusy: !liveHub ? livePort : null };
279
+ }).catch((error) => {
280
+ // The pane answers CAPABILITY_MISSING on use; the reason belongs on the
281
+ // launcher's terminal rather than nowhere.
282
+ console.error(`cozyclay: Studio live tools unavailable: ${error?.message ?? error}`);
283
+ for (const line of error?.lines ?? []) console.error(`cozyclay: ${line}`);
284
+ return { error };
285
+ });
262
286
  }
263
287
 
264
- export function createAgentHandler({ auth = defaultAuth, codex, models, codexBaseUrl, cliproxyBaseUrl, env, fauxProvider, handlers, liveHub, port, getBridgeOrigin, studioRuntime, clock = Date.now, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, sessionStore: injectedSessionStore } = {}) {
288
+ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBaseUrl, cliproxyBaseUrl, env, fauxProvider, handlers, liveHub, mcpRuntime, port, studioRuntime, clock = Date.now, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, sessionStore: injectedSessionStore } = {}) {
265
289
  const requestContext = new AsyncLocalStorage();
266
290
  codex ||= defaultClient(auth, requestContext);
267
- const runtime = handlers !== undefined || liveHub !== undefined ? Promise.resolve({ handlers: handlers ?? [], liveHub }) : liveToolsRuntime();
291
+ const runtime = handlers !== undefined || liveHub !== undefined ? Promise.resolve({ handlers: handlers ?? [], liveHub }) : liveToolsRuntime(mcpRuntime);
268
292
  const renderGuidance = async (environment) => {
269
293
  try {
270
294
  const { handlers: tools, liveHub: hub } = await runtime;
@@ -306,7 +330,6 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
306
330
  const sessionStore = injectedSessionStore ?? createSessionStore();
307
331
  const studioSessions = new Map();
308
332
  const studioEvents = new Map();
309
- let ownedStudioRuntime = studioRuntime || null;
310
333
  const studioOwnerTokens = new Map();
311
334
  const parseCookies = req => Object.fromEntries(String(req.headers.cookie || "").split(";").map(part => part.trim().split("=")).filter(([key, value]) => key && value).map(([key, value]) => [key, decodeURIComponent(value)]));
312
335
  const pruneStudioSessions = () => {
@@ -323,13 +346,6 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
323
346
  if (!owner || supplied !== owner) throw Object.assign(new Error("Studio session owner mismatch."), { code: "AUTH_REQUIRED" });
324
347
  return owner;
325
348
  };
326
- const studioRuntimeFor = async hub => {
327
- if (ownedStudioRuntime) return ownedStudioRuntime;
328
- const { createStudioMotionRuntime } = await import("./motion-runtime.mjs");
329
- if (!hub) return null;
330
- ownedStudioRuntime = createStudioMotionRuntime({ liveHub: hub, getBridgeOrigin, clock });
331
- return ownedStudioRuntime;
332
- };
333
349
  const emitStudioEvent = (turnId, event) => {
334
350
  const record = studioEvents.get(turnId) || { next: 0, events: [], listeners: new Set(), terminal: false };
335
351
  // Execution telemetry is advisory and is validated key-by-key in the
@@ -353,7 +369,6 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
353
369
  if (kind !== "signed_out" && kind !== "replaced") return;
354
370
  for (const session of sessions.values()) session.controller?.abort();
355
371
  for (const session of studioSessions.values()) session.controller?.abort();
356
- void ownedStudioRuntime?.dispose?.(); ownedStudioRuntime = studioRuntime || null;
357
372
  for (const runner of [...workflowRunners.values(), ...studioRunners.values()]) void runner.close?.();
358
373
  workflowRunners.clear(); studioRunners.clear();
359
374
  workflowModels = models;
@@ -401,36 +416,21 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
401
416
  // An owned retired id is still stale while another motion job is active:
402
417
  // this Stop cannot acknowledge the current turn's different job.
403
418
  if (value.jobId && session.activeJobId && value.jobId !== session.activeJobId) throw new StudioProtocolError("STALE_TARGET", "Stop does not target the active motion job.");
404
- // #379 / 16r: a job's id is only "acknowledged" by THIS turn's held motion
405
- // tool — session.activeJobId now stays set only while that job is genuinely
406
- // still in flight (or pending an explicit accept), and activeJobTurnId ties
407
- // it to the turn that admitted it. A retired job from an earlier turn must
408
- // never make a later, unrelated turn's Stop go quiet.
419
+ // Only this turn's held editor job can settle the tool quietly. A retired
420
+ // job from an earlier turn must never silence an unrelated turn's Stop.
409
421
  const acknowledged = Boolean(session.activeJobId) && session.activeJobTurnId === value.turnId;
410
- // The runtime is the only thing that knows whether the job was applied.
411
- // Forwarding its outcome keeps the panel from turning "I could not find
412
- // out" into "nothing was applied"; a discarded outcome reads as proof.
422
+ // The editor's outcome, not a sidecar abort, establishes application.
413
423
  let outcome = null;
414
- if (jobId && ownedStudioRuntime?.stop) outcome = await ownedStudioRuntime.stop(jobId);
424
+ if (jobId) outcome = await session.controlJob('job.cancel', jobId);
415
425
  if (jobId && session.activeJobId === jobId) { session.activeJobId = null; session.activeJobTurnId = null; }
416
- // #379 / 16q: `session.controller.signal` is the SAME signal wired into the
417
- // active turn's prompt context (`:520 signal: controller.signal`), which
418
- // pi's `withAbortSignal`/`awaitWithContext` races against every awaited
419
- // internal step — aborting it here interrupts the in-flight generate_motion
420
- // tool call's OWN completion machinery before it can settle, well before
421
- // pi's own graceful `lane.abort()` (below) ever runs. A job the runtime
422
- // actually acknowledged already resolved the held tool call with its real
423
- // outcome via `runtime.stop()` above; the only abort this stop still needs
424
- // is the graceful one that stops the model from being prompted again, so
425
- // `session.controller` must NOT be touched on that path (no other tool is
426
- // concurrently in flight while generate_motion holds the turn). A stop
427
- // for a job this turn never acknowledged (none active, or a retired one
428
- // from an earlier turn) never resolved anything for THIS turn and stays a
429
- // plain (loud) abort on both signals.
426
+ // Let the acknowledged job settle its held tool with that outcome before
427
+ // aborting the model lane. Aborting the shared signal would interrupt the
428
+ // tool first and lose the editor's proof. Without a held job, abort both.
430
429
  if (!acknowledged) session.controller?.abort();
431
430
  await session.modelSession?.abort?.("studio stop", acknowledged ? { quiet: true } : undefined);
432
431
  json(res, 200, { ok: true, status: jobId ? "stopped" : "detached", ...(outcome ? { outcome: { status: outcome.status ?? null, code: outcome.code ?? null, mutated: outcome.mutated ?? null } } : {}) }); return true;
433
432
  }
433
+ if (!studioRuntime && !hub && hubDeps.hubPortBusy) throw new StudioProtocolError("CAPABILITY_MISSING", `The Studio live hub could not start: port ${hubDeps.hubPortBusy} is in use by another CozyClay or its dev server. Free that port and relaunch.`);
434
434
  if (!studioRuntime && (!hub?.command || !hub?.workspaceId)) throw new StudioProtocolError("CAPABILITY_MISSING", "Studio execution is not installed.");
435
435
  if (!value.context.host.workspaceHandle) throw new StudioProtocolError("LIVE_HUB_UNAVAILABLE", "A connected editor handle is required.");
436
436
  let session = studioSessions.get(value.sessionId);
@@ -440,7 +440,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
440
440
  studioOwner(req, value.sessionId, true);
441
441
  let persisted = null;
442
442
  try { persisted = sessionStore.read(value.sessionId); } catch { persisted = null; }
443
- session = { owner: studioOwnerTokens.get(value.sessionId), history: persisted?.history ?? [], persistedItems: persisted?.history?.length ?? 0, meta: persisted?.meta ?? null, turns: new Map(), controller: null, activeJobId: null, activeJobTurnId: null, motionJobIds: new Set(Array.isArray(persisted?.meta?.motionJobIds) ? persisted.meta.motionJobIds.filter(id => typeof id === "string") : []), generationPrompt: null, host: null, updatedAt: clock() };
443
+ session = { owner: studioOwnerTokens.get(value.sessionId), history: persisted?.history ?? [], persistedItems: persisted?.history?.length ?? 0, meta: persisted?.meta ?? null, turns: new Map(), controller: null, activeJobId: null, activeJobTurnId: null, motionJobIds: new Set(Array.isArray(persisted?.meta?.motionJobIds) ? persisted.meta.motionJobIds.filter(id => typeof id === "string") : []), host: null, updatedAt: clock() };
444
444
  studioSessions.set(value.sessionId, session);
445
445
  }
446
446
  session.updatedAt = clock();
@@ -468,33 +468,52 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
468
468
  const admission = {
469
469
  commandId: () => randomUUID(), host: studioIdentity(value.context.host), revision: value.context.revision.scene,
470
470
  refresh: async () => {
471
- const refreshed = studioRuntime?.readContext ? await studioRuntime.readContext(value.context.host) : await authoritativeStudioContext(value, hub);
471
+ // Read at the admitted host: a scene action may have opened another
472
+ // scene of this workspace during the turn (studio-tools adopts it).
473
+ const host = { ...value.context.host, ...admission.host };
474
+ const refreshed = studioRuntime?.readContext ? await studioRuntime.readContext(host) : await authoritativeStudioContext({ ...value, context: { ...value.context, host } }, hub);
472
475
  admission.revision = refreshed.revision.scene;
473
476
  },
474
477
  };
475
- const runtimeForJob = await studioRuntimeFor(hub);
476
- const tools = createStudioTools({ liveHub: hub, workspaceHandle: value.context.host.workspaceHandle, session: { signal: controller.signal, admission }, resolveImage: async (id, correlation) => hub.command("resolve_studio_image", { imageId: id, ...correlation }, value.context.host.workspaceHandle) });
477
- const motion = async args => {
478
- if (session.generationPrompt === value.text) throw new StudioProtocolError("AUTH_REQUIRED", "This generation request already has a retained result; start a new explicit request.");
479
- if (!runtimeForJob) throw new StudioProtocolError("CAPABILITY_MISSING", "Studio motion runtime is unavailable.");
480
- const character = value.context.entities.find(entity => entity.id === args.characterId && entity.kind === "character");
481
- if (!character) throw new StudioProtocolError("TARGET_NOT_READY", "The admitted character is unavailable.");
482
- const commandId = randomUUID(); const host = { ...value.context.host, workspaceHandle: value.context.host.workspaceHandle };
483
- const admissionResult = runtimeForJob.admit({ hostBinding: host, characterId: args.characterId, targetToken: character.token, turnId: value.turnId, commandId, authorization: { id: randomUUID(), generations: 1 }, source: args.source, repair: args.repair ?? "bounded" });
484
- session.motionJobIds.add(admissionResult.jobId); persistenceMeta.motionJobIds = [...session.motionJobIds]; session.activeJobId = admissionResult.jobId; session.activeJobTurnId = value.turnId; session.generationPrompt = value.text;
485
- const unsubscribe = runtimeForJob.subscribe(admissionResult.jobId, event => send({ ...event, sourceEventSeq: event.eventSeq }));
486
- // Subscription precedes start, including replay of the queued admission event.
487
- let outcome;
488
- try { outcome = await runtimeForJob.start(admissionResult.jobId); if (outcome?.ok && outcome.status === "installed") send({ type: "receipt", receipt: outcome }); return outcome; }
489
- finally {
490
- unsubscribe();
491
- // A job still awaiting an explicit accept (review_required) stays "active"
492
- // for the accept route's ownership check (:678); every other outcome —
493
- // installed, cancelled, proved-not-applied, failed, or a thrown error —
494
- // is terminal for this session and must not leak into a later turn's Stop.
495
- if (session.activeJobId === admissionResult.jobId && outcome?.status !== "review_required") { session.activeJobId = null; session.activeJobTurnId = null; }
478
+ // One motion generation per user message, whichever path starts it:
479
+ // generate_motion below and a run_action job action share this gate.
480
+ const generation = { used: false, failures: 0 };
481
+ // The editor's command index (authoritative context) declares each action's
482
+ // generation and hub timeout; no sidecar list of actions exists.
483
+ const controlJob = async (action, jobId) => {
484
+ await admission.refresh();
485
+ return hub.command('run_action', { name: 'run_action', args: { action, args: { jobId, ...(action === 'job.await' ? { timeoutMs: 300_000 } : {}) } },
486
+ commandId: randomUUID(), host: admission.host, expectedRevision: admission.revision }, value.context.host.workspaceHandle, { timeoutMs: 300_000 });
487
+ };
488
+ session.controlJob = controlJob;
489
+ const onJob = async started => {
490
+ const jobId = started.jobId;
491
+ session.motionJobIds.add(jobId);
492
+ persistenceMeta.motionJobIds = [...session.motionJobIds];
493
+ session.activeJobId = jobId; session.activeJobTurnId = value.turnId;
494
+ const cancel = () => { void controlJob('job.cancel', jobId).catch(error => send({ type: 'error', code: error.code ?? 'CANCEL_FAILED', message: error.message })); };
495
+ controller.signal.addEventListener('abort', cancel, { once: true });
496
+ if (controller.signal.aborted) cancel();
497
+ send({ type: 'job.state', jobId, commandId: started.commandId, state: 'generating', phase: 'generating' });
498
+ try {
499
+ const outcome = await controlJob('job.await', jobId);
500
+ send({ type: 'job.state', jobId, commandId: outcome.commandId, state: outcome.ok ? 'installed' : outcome.code === 'CANCELLED' ? 'cancelled' : 'failed', phase: 'complete' });
501
+ return outcome;
502
+ } finally {
503
+ controller.signal.removeEventListener('abort', cancel);
504
+ if (session.activeJobId === jobId) { session.activeJobId = null; session.activeJobTurnId = null; }
496
505
  }
497
506
  };
507
+ const tools = createStudioTools({ liveHub: hub, workspaceHandle: value.context.host.workspaceHandle, session: { signal: controller.signal, admission, generation, onJob, actionIndex: current?.actionIndex ?? [] }, resolveImage: async (id, correlation) => hub.command("resolve_studio_image", { imageId: id, ...correlation }, value.context.host.workspaceHandle) });
508
+ const motion = async args => {
509
+ // Private artifact IDs belonged to the retired sidecar runtime. Reuse
510
+ // retained editor takes through motion.loadVersion, not another installer.
511
+ if (args.source.kind !== 'generate') throw new StudioProtocolError('CAPABILITY_MISSING', 'Private motion artifacts are no longer retained. Use motion.loadVersion for an editor take.');
512
+ await tools.internal.invoke('inspect_studio', { scope: 'motion', ids: [args.characterId] });
513
+ const receipt = await tools.internal.invoke('generate_motion', args);
514
+ if (receipt.ok && receipt.status === 'completed') send({ type: 'receipt', receipt });
515
+ return receipt;
516
+ };
498
517
  const modelTools = tools.map(tool => ({
499
518
  ...tool,
500
519
  handler: async args => {
@@ -699,14 +718,6 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
699
718
  }
700
719
  return true;
701
720
  }
702
- if (path.startsWith("/agent/jobs/") && path.endsWith("/accept") && req.method === "POST") {
703
- if (!await hasAnyCredential()) { json(res, 401, { error: { code: "AUTH_REQUIRED", message: "Sign in or configure a provider key." } }); return true; }
704
- let value; try { value = await readBody(req); } catch { json(res, 400, { error: "invalid request" }); return true; }
705
- const jobId = decodeURIComponent(path.slice("/agent/jobs/".length, -"/accept".length)); const session = studioSessions.get(value?.sessionId);
706
- if (!session || session.owner !== parseCookies(req).studio_owner || value.surface !== "studio" || value.explicitUnverifiedAcceptance !== true || !session.turns.has(value.turnId) || session.activeJobId !== jobId) { json(res, 403, { error: { code: "AUTH_REQUIRED", message: "Only the owning Studio UI may accept this candidate." } }); return true; }
707
- try { const receipt = await ownedStudioRuntime.accept(jobId); const record = studioEvents.get(value.turnId); if (record) { emitStudioEvent(value.turnId, { type: "receipt", receipt }); emitStudioEvent(value.turnId, { type: "done" }); record.terminal = true; } session.activeJobId = null; session.activeJobTurnId = null; json(res, 200, { receipt }); } catch (error) { json(res, 409, { error: { code: error.code || "VERIFICATION_FAILED", message: error.message } }); }
708
- return true;
709
- }
710
721
  if (req.method !== "POST" || !["/agent/turn", "/agent/stop"].includes(path)) {
711
722
  json(res, 404, { error: "not found" }); return true;
712
723
  }
@@ -841,7 +852,6 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
841
852
  for (const runner of workflowRunners.values()) await runner.close?.();
842
853
  for (const runner of studioRunners.values()) await runner.close?.();
843
854
  workflowRunners.clear(); studioRunners.clear();
844
- if (ownedStudioRuntime?.dispose) await ownedStudioRuntime.dispose();
845
855
  studioSessions.clear(); studioEvents.clear(); studioOwnerTokens.clear();
846
856
  sessions.clear();
847
857
  const { liveHub: hub } = await runtime;
@@ -228,11 +228,18 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
228
228
  const messages = entries.filter((entry) => entry.type === "message").map((entry) => entry.message);
229
229
  if (messages.length <= state.persisted) return;
230
230
  const aborted = state.active?.abortRequested === true;
231
- const pending = messages.slice(state.persisted).filter((message) => !(aborted && message?.role === "assistant") && !(message?.role === "assistant" && message?.stopReason === "error"));
231
+ const newlyGenerated = messages.slice(state.persisted);
232
+ const failedAssistant = newlyGenerated.find((message) => message?.role === "assistant" && (aborted || message.stopReason === "error"));
233
+ const pending = newlyGenerated.filter((message) => !(aborted && message?.role === "assistant") && !(message?.role === "assistant" && message?.stopReason === "error"));
232
234
  state.persisted = messages.length;
233
- if (!pending.length) return;
234
235
  const input = state.lastInput || {};
235
- await sessionStore.append(sessionId, pending, { surface, ...(input.meta || {}) });
236
+ if (pending.length) await sessionStore.append(sessionId, pending, { surface, ...(input.meta || {}) });
237
+ if (failedAssistant && sessionStore.appendTurnError) {
238
+ const rawMessage = failedAssistant.errorMessage || "The model or live editor could not complete this turn.";
239
+ const message = aborted ? "The turn was aborted." : sanitizeUpstreamDetail(JSON.stringify({ message: rawMessage })) || rawMessage;
240
+ const code = aborted ? "aborted" : classifyProviderError(rawMessage);
241
+ await sessionStore.appendTurnError(sessionId, { code, message, at: new Date().toISOString() }, { surface, ...(input.meta || {}) });
242
+ }
236
243
  };
237
244
 
238
245
  // pi calls AgentTool.execute(toolCallId, params, signal, onUpdate) — the