cozyclay 2.0.0 → 2.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 (260) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +219 -38
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +6 -5
  19. package/bin/cozyclay.mjs +12 -3
  20. package/bin/live/cli.mjs +2 -3
  21. package/bin/live/client.mjs +1 -1
  22. package/bin/live/studio.mjs +5 -2
  23. package/bin/mcp-runtime.mjs +12 -2
  24. package/bin/telemetry-state.mjs +3 -3
  25. package/dist/app/index.html +6 -6
  26. package/dist/assets/analytics-B41FmPYI.js +1 -0
  27. package/dist/assets/app-Do-Og5Pu.css +1 -0
  28. package/dist/assets/app-DwP-tl2x.js +4920 -0
  29. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  30. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  31. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  32. package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
  33. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  34. package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
  35. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/index.html +328 -222
  38. package/dist/sitemap.xml +5 -5
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +801 -259
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +196 -3
  72. package/src/ardy/timeline.jsx +117 -29
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +1 -4
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +43 -24
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +16 -3
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +89 -32
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion/generation.js +1 -1
  127. package/src/motion-store.js +4 -4
  128. package/src/motion-trail.js +5 -11
  129. package/src/multimodel-ingest.js +1 -3
  130. package/src/object-catalog.jsx +7 -5
  131. package/src/object-gizmo.jsx +45 -31
  132. package/src/object-path.js +310 -67
  133. package/src/object-travel.js +224 -0
  134. package/src/otio.js +8 -3
  135. package/src/panels/CharacterTransformPanel.jsx +24 -6
  136. package/src/panels/ObjectTransformPanel.jsx +4 -3
  137. package/src/panels/PoseImagePanel.jsx +205 -0
  138. package/src/panels/PromptBlocksPanel.jsx +3 -1
  139. package/src/panels/ProxyFigurePanel.jsx +56 -0
  140. package/src/panels/ReferenceImageField.jsx +1 -1
  141. package/src/panels/RigControlPanel.jsx +24 -3
  142. package/src/panels/RigPanel.jsx +2 -1
  143. package/src/panels/SculptPanel.jsx +405 -0
  144. package/src/panels/SubjectBox.jsx +1 -1
  145. package/src/panels/SubjectsPanel.jsx +0 -2
  146. package/src/panels/VideoCapturePanel.jsx +2 -1
  147. package/src/panels/pose-image.css +42 -0
  148. package/src/panels/pose.css +3 -0
  149. package/src/panels/sculpt.css +162 -0
  150. package/src/plan-diagram.js +184 -0
  151. package/src/planview.jsx +127 -80
  152. package/src/playground.js +3 -5
  153. package/src/pose-fade.js +77 -0
  154. package/src/pose-gizmo.css +23 -0
  155. package/src/pose-gizmo.js +96 -0
  156. package/src/posestudio.jsx +134 -83
  157. package/src/project-browser.css +53 -0
  158. package/src/project-browser.jsx +52 -3
  159. package/src/project.js +55 -24
  160. package/src/props.jsx +177 -36
  161. package/src/proxy-figure.jsx +69 -0
  162. package/src/range-pin-panel.jsx +0 -1
  163. package/src/receipt-findings.js +20 -0
  164. package/src/render-passes.js +0 -1
  165. package/src/resource-status.jsx +1 -1
  166. package/src/root-path.js +48 -0
  167. package/src/route-owner.js +52 -0
  168. package/src/scene-asset-cache.js +1 -2
  169. package/src/scene-assets.js +12 -4
  170. package/src/scene-fbx.js +1 -1
  171. package/src/scene-mesh-cache.js +0 -1
  172. package/src/scene-objects.js +319 -19
  173. package/src/scenes.js +62 -17
  174. package/src/sculpt-blueprint.js +65 -0
  175. package/src/sculpt-compare.js +384 -0
  176. package/src/sculpt-geometry.js +141 -0
  177. package/src/sculpt-mode.js +168 -0
  178. package/src/sculpt-recipe.js +385 -0
  179. package/src/sculpt-run.js +351 -0
  180. package/src/sculpt-stage.jsx +221 -0
  181. package/src/settings-menu.jsx +1 -1
  182. package/src/shell/BoardSlot.jsx +341 -0
  183. package/src/shell/BottomDock.jsx +58 -14
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/TopBar.jsx +17 -3
  188. package/src/shell/ViewportToolbar.jsx +18 -3
  189. package/src/shell/dock.css +239 -0
  190. package/src/shell/glass-regions.css +3 -2
  191. package/src/shell/log-store.js +4 -4
  192. package/src/shell/mode.css +20 -0
  193. package/src/shell/studio-shell-context.js +4 -1
  194. package/src/shot-authoring.js +69 -6
  195. package/src/stable-items.js +1 -1
  196. package/src/stage-attach-frames.js +169 -0
  197. package/src/stage-canvas-rigs.jsx +402 -0
  198. package/src/stage-capture.jsx +216 -0
  199. package/src/stage-characters.jsx +302 -0
  200. package/src/stage-editor-gizmos.jsx +1229 -0
  201. package/src/stage-labels-ko.js +204 -0
  202. package/src/stage-startup-defaults.js +182 -0
  203. package/src/starter-scenes.js +1 -1
  204. package/src/storyboard.js +24 -5
  205. package/src/studio-actions.js +42 -9
  206. package/src/studio-agent-commands.js +186 -23
  207. package/src/studio-agent-context.js +11 -9
  208. package/src/studio-agent-motion.js +2 -1
  209. package/src/studio-agent-protocol.js +56 -23
  210. package/src/studio-app-binding.js +52 -23
  211. package/src/studio-contact-sheet.js +3 -3
  212. package/src/studio-elements.js +11 -2
  213. package/src/studio-geometry-facts.js +114 -0
  214. package/src/styles.css +111 -10
  215. package/src/theme.js +1 -1
  216. package/src/timeline-fps.js +13 -0
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +110 -20
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/kimodo/effector-constraints.mjs +4 -4
  248. package/tools/kimodo/generate.mjs +1 -1
  249. package/tools/knip-baseline.json +331 -0
  250. package/tools/loc-baseline.json +126 -0
  251. package/tools/process-supervisor.mjs +1 -1
  252. package/tools/projflow/replay.mjs +4 -4
  253. package/tools/projflow/runner.mjs +3 -3
  254. package/tools/qa/studio-control-count.mjs +2 -0
  255. package/tools/run-tests.mjs +96 -7
  256. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  257. package/dist/assets/app-C1sdVbg4.css +0 -1
  258. package/dist/assets/app-CA55tcI3.js +0 -4876
  259. package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
  260. package/dist/assets/workflow-dNPGY9xz.js +0 -179
@@ -10,6 +10,7 @@ import { createAgentTools, SYSTEM_PROMPT, pickWorkspace } from "./agent-tools.mj
10
10
  import { createVideoAdapters, validateFalVideoRequest } from "./video-adapters.mjs";
11
11
  import { createSessionStore, transcriptFromHistory } from "./session-store.mjs";
12
12
  import { createAgentRunner } from "./agent-runner.mjs";
13
+ import { STUDIO_SYSTEM_PROMPT_STORYBOARD } from "./studio-prompt.mjs";
13
14
 
14
15
  // Values the codex backend accepts for reasoning.effort (its own 400 lists them).
15
16
  export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
@@ -19,7 +20,7 @@ const json = (res, status, value) => {
19
20
  res.end(JSON.stringify(value));
20
21
  };
21
22
 
22
- export function allowAgentOrigin(req, port) {
23
+ function allowAgentOrigin(req, port) {
23
24
  return [`http://127.0.0.1:${port}`, `http://${"local" + "host"}:${port}`].includes(req.headers.origin)
24
25
  || (req.headers.origin === undefined && req.method === "GET"
25
26
  && [`127.0.0.1:${port}`, `localhost:${port}`].includes(req.headers.host));
@@ -27,6 +28,8 @@ export function allowAgentOrigin(req, port) {
27
28
 
28
29
  // Two 1920x1080 PNG data URLs (frame + reference) fit comfortably in this.
29
30
  const IMAGE_BODY_LIMIT = 24 * 1024 * 1024;
31
+ // The sizes gpt-image-2 accepts; omitted means the backend's own choice.
32
+ const IMAGE_SIZES = ["auto", "1024x1024", "1536x1024", "1024x1536"];
30
33
 
31
34
  // Attached scene references (#167): identity sheets and the environment
32
35
  // reference. Capped because every one of them is another full image the
@@ -41,6 +44,10 @@ const ATTACHMENTS_MAX = 4;
41
44
  const ATTACHMENT_MAX_CHARS = 6_000_000;
42
45
  const TURN_BODY_LIMIT = ATTACHMENTS_MAX * ATTACHMENT_MAX_CHARS + 64 * 1024;
43
46
 
47
+ // How long GET /agent/turn/<id>/supervisor holds a request for a review that
48
+ // is still running before it answers 204 (#728).
49
+ const SUPERVISOR_WAIT_MS = 180_000;
50
+
44
51
  /** Reject anything that is not a short list of inline {dataUrl, name?} images.
45
52
  * The Studio envelope validates its own copy; this is the legacy body's. */
46
53
  function validAttachments(attachments) {
@@ -60,7 +67,6 @@ const agentToolCategory = (name) => {
60
67
  // Studio families are authored edits or reads; the analytics vocabulary has
61
68
  // no Studio-specific category, so a write says so and everything else does not.
62
69
  if (["arrange_objects", "arrange_characters", "patch_elements", "frame_shot", "undo_edit"].includes(name)) return "scene_write";
63
- if (["inspect_studio", "operate_studio", "verify_result", "generate_motion"].includes(name)) return "other";
64
70
  if (name === "run_workflow") return "workflow_run";
65
71
  if (name === "describe_workflow" || name === "focus_workflow_node") return "workflow_read";
66
72
  if (["add_workflow_node", "update_workflow_node", "remove_workflow_node", "connect_workflow_nodes", "disconnect_workflow_nodes", "set_workflow_node_output"].includes(name)) return "workflow_write";
@@ -75,6 +81,55 @@ const agentFailureCode = (error, signal, tool = false) => advisory(() => {
75
81
  if (error?.status === 429) return "rate_limited";
76
82
  return tool ? "tool_failed" : "upstream";
77
83
  }, tool ? "tool_failed" : "upstream");
84
+ /**
85
+ * One user message is one editor transaction: every Studio turn whose editor
86
+ * reports a previsMode opens agent.turn.begin and closes agent.turn.finish, so
87
+ * all of its mutations land as ONE history entry and one receipt frame. A
88
+ * context without a previsMode comes from an editor that predates turn
89
+ * transactions and runs unwrapped. rollback() cancels the open transaction
90
+ * (restoring the pre-turn document) and reopens it for the rest of the turn.
91
+ */
92
+ const agentTurn = ({ hub, context, turnId, admission }) => {
93
+ const wrapped = Boolean(context.scene?.previsMode);
94
+ const host = Object.fromEntries(["workspaceId", "documentEpoch", "sceneId", "sceneEpoch"].map(key => [key, context.host[key]]));
95
+ const control = async action => {
96
+ const receipt = await hub.command("run_action", {
97
+ name: "run_action", args: { action, args: { turnId } }, turnId,
98
+ commandId: randomUUID(), host, expectedRevision: admission.revision,
99
+ }, context.host.workspaceHandle);
100
+ if (!receipt.ok) throw Object.assign(new Error(receipt.message), { code: receipt.code });
101
+ return receipt;
102
+ };
103
+ const open = async () => { await control("agent.turn.begin"); admission.turnId = turnId; };
104
+ return {
105
+ async rollback() {
106
+ if (admission.turnId !== turnId) return null;
107
+ delete admission.turnId;
108
+ const receipt = await control("agent.turn.cancel");
109
+ await admission.refresh();
110
+ await open();
111
+ return receipt;
112
+ },
113
+ run: start => async function* (input) {
114
+ if (!wrapped) { yield* start(input); return; }
115
+ await open();
116
+ let terminal;
117
+ try {
118
+ for await (const frame of start(input)) {
119
+ if (frame.type === "done") terminal = frame; else yield frame;
120
+ }
121
+ } finally {
122
+ // A rollback whose reopen failed has already closed the transaction.
123
+ if (admission.turnId === turnId) {
124
+ delete admission.turnId;
125
+ const receipt = await control("agent.turn.finish");
126
+ if (receipt.authored) yield { type: "receipt", receipt };
127
+ }
128
+ }
129
+ if (terminal) yield terminal;
130
+ },
131
+ };
132
+ };
78
133
  // These existing canvas commands only return a node/edge after publishing a
79
134
  // new insertion. Read/focus, generic accepted responses, update no-ops and run
80
135
  // outputs (which can echo old values) are deliberately not application proof.
@@ -134,6 +189,8 @@ function validReferences(references) {
134
189
  * owns the geometry, each character sheet owns one performer's look and the
135
190
  * environment reference owns the location.
136
191
  */
192
+ export const FRAME_COMPOSITION_GUIDANCE = "Use the first image (a clay blocking frame) as the layout: keep its composition and camera angle, and keep the pose but make it look natural. Render it as:";
193
+
137
194
  export function referenceGuidance(references = []) {
138
195
  const list = Array.isArray(references) ? references : [];
139
196
  if (!list.length) return "";
@@ -143,6 +200,8 @@ export function referenceGuidance(references = []) {
143
200
  lines.push(`Character ${entry.name || "reference"}: match the identity, face, hair and wardrobe from the attached character sheet.`);
144
201
  } else if (entry.role === "environment") {
145
202
  lines.push("Environment: take the location look, materials, palette and lighting from the attached environment reference.");
203
+ } else if (entry.role === "reference") {
204
+ lines.push(`Reference ${entry.name || "image"}: use it for the look the prompt asks for (subject, wardrobe, style); keep the exact pose, body position and camera framing of the first image.`);
146
205
  }
147
206
  }
148
207
  return `\n${lines.join("\n")}`;
@@ -174,7 +233,7 @@ function quotaEvent(codex, headers) {
174
233
  // one line — without it neither the log nor the panel can say why a turn died.
175
234
  const UPSTREAM_DETAIL_MAX = 200;
176
235
  export function sanitizeUpstreamDetail(body) {
177
- let message = null;
236
+ let message;
178
237
  try {
179
238
  const parsed = JSON.parse(body);
180
239
  const candidate = parsed?.error?.message ?? parsed?.error ?? parsed?.message ?? parsed?.detail;
@@ -249,7 +308,12 @@ function liveToolsRuntime(mcpRuntime) {
249
308
  return liveToolsModules(mcpRuntime).then(async ([registry, { startLiveHub }]) => {
250
309
  const owner = liveHubOwner();
251
310
  const token = randomBytes(32).toString("hex");
252
- const liveHub = await startLiveHub(Number(process.env.COZYCLAY_LIVE_PORT ?? 5184), { token, owner });
311
+ const livePort = Number(process.env.COZYCLAY_LIVE_PORT ?? 5184);
312
+ const liveHub = await startLiveHub(livePort, { token, owner });
313
+ // EADDRINUSE comes back as null: another CozyClay (or its dev server) owns
314
+ // the port, and the Studio page is built to connect to that port, so the
315
+ // pane would answer every turn with a misleading capability error.
316
+ 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.`);
253
317
  registry.setLiveHub(liveHub);
254
318
  const handlers = registry.createToolHandlers().map((tool) => ({
255
319
  ...tool,
@@ -270,7 +334,7 @@ function liveToolsRuntime(mcpRuntime) {
270
334
  publishLiveEndpoint({ port: liveHub.port, token, owner });
271
335
  liveHub.server?.once("close", () => removeLiveEndpoint(liveHub.port));
272
336
  }
273
- return { liveHub, handlers };
337
+ return { liveHub, handlers, hubPortBusy: !liveHub ? livePort : null };
274
338
  }).catch((error) => {
275
339
  // The pane answers CAPABILITY_MISSING on use; the reason belongs on the
276
340
  // launcher's terminal rather than nowhere.
@@ -280,7 +344,10 @@ function liveToolsRuntime(mcpRuntime) {
280
344
  });
281
345
  }
282
346
 
283
- 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 } = {}) {
347
+ // `supervisor` (#728) defaults off only under an injected scripted provider:
348
+ // a test's script is an exact queue of replies, and a review would take one
349
+ // meant for a later turn. The real sidecar never injects one.
350
+ 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, supervisor = !fauxProvider } = {}) {
284
351
  const requestContext = new AsyncLocalStorage();
285
352
  codex ||= defaultClient(auth, requestContext);
286
353
  const runtime = handlers !== undefined || liveHub !== undefined ? Promise.resolve({ handlers: handlers ?? [], liveHub }) : liveToolsRuntime(mcpRuntime);
@@ -342,6 +409,9 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
342
409
  return owner;
343
410
  };
344
411
  const emitStudioEvent = (turnId, event) => {
412
+ // The supervisor note lands after the turn ended (#728): it joins the turn's
413
+ // record, terminal or not, but never resurrects one that was retired.
414
+ if (event.type === "supervisor" && !studioEvents.has(turnId)) return;
345
415
  const record = studioEvents.get(turnId) || { next: 0, events: [], listeners: new Set(), terminal: false };
346
416
  // Execution telemetry is advisory and is validated key-by-key in the
347
417
  // browser: it carries no replay cursor, is never retained for a resume,
@@ -372,10 +442,10 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
372
442
  });
373
443
 
374
444
  const studioIdentity = host => Object.fromEntries(["workspaceId", "documentEpoch", "sceneId", "sceneEpoch"].map(key => [key, host[key]]));
375
- const writeStudioStream = (res, record, after = 0, req = null) => {
445
+ const writeStudioStream = (res, record, after = 0) => {
376
446
  res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" }); res.flushHeaders?.();
377
447
  let cursor = after; const send = event => {
378
- if (res.destroyed) return;
448
+ if (res.destroyed || res.writableEnded) return;
379
449
  if (event.eventSeq === undefined) { res.write(`data: ${JSON.stringify(event)}\n\n`); return; }
380
450
  if (event.eventSeq > cursor) { cursor = event.eventSeq; res.write(`data: ${JSON.stringify(event)}\n\n`); }
381
451
  };
@@ -396,8 +466,8 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
396
466
  };
397
467
  const handleStudioTurn = async (req, res, value, path) => {
398
468
  const { StudioProtocolError, validateStudioContextFreshness } = await import("../../src/studio-agent-protocol.js");
399
- const [{ createStudioTools }, { encodeStudioContext }] = await Promise.all([
400
- import("./studio-tools.mjs"), import("../../src/studio-agent-context.js"),
469
+ const [{ createStudioTools }, { encodeStudioContext }, { createScriptTool }, { runSupervisor, shouldSupervise }] = await Promise.all([
470
+ import("./studio-tools.mjs"), import("../../src/studio-agent-context.js"), import("./script-tool.mjs"), import("./supervisor.mjs"),
401
471
  ]);
402
472
  const hubDeps = await runtime; const hub = hubDeps.liveHub || liveHub;
403
473
  if (path === "/agent/stop") {
@@ -425,48 +495,71 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
425
495
  await session.modelSession?.abort?.("studio stop", acknowledged ? { quiet: true } : undefined);
426
496
  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;
427
497
  }
498
+ 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.`);
428
499
  if (!studioRuntime && (!hub?.command || !hub?.workspaceId)) throw new StudioProtocolError("CAPABILITY_MISSING", "Studio execution is not installed.");
429
500
  if (!value.context.host.workspaceHandle) throw new StudioProtocolError("LIVE_HUB_UNAVAILABLE", "A connected editor handle is required.");
430
501
  let session = studioSessions.get(value.sessionId);
431
502
  const suppliedOwner = parseCookies(req).studio_owner;
432
503
  if (session && suppliedOwner && session.owner !== suppliedOwner) throw new StudioProtocolError("AUTH_REQUIRED", "Studio session owner mismatch.");
504
+ const existing = session?.turns.get(value.turnId);
505
+ if (existing) { session.updatedAt = clock(); writeStudioStream(res, existing, 0); return true; }
506
+ let current;
507
+ try { current = studioRuntime?.readContext ? await studioRuntime.readContext(value.context.host) : await authoritativeStudioContext(value, hub); }
508
+ catch (error) { if (error instanceof StudioProtocolError) throw error; if (error?.code) throw new StudioProtocolError(error.code, error.message); throw new StudioProtocolError("CAPABILITY_MISSING", "The connected editor does not expose authoritative Studio context."); }
509
+ validateStudioContextFreshness(value.context, current);
510
+ // The session, and the owner token only this turn's response can hand
511
+ // the browser, exist only once the turn is admitted. Minting them before
512
+ // a refusal (the editor still reconnecting after a restart) left a
513
+ // session whose owner the browser never received: its cookie from the
514
+ // previous server then failed every retry with "owner mismatch".
433
515
  if (!session) {
434
516
  studioOwner(req, value.sessionId, true);
435
517
  let persisted = null;
436
- try { persisted = sessionStore.read(value.sessionId); } catch { persisted = null; }
518
+ try { persisted = sessionStore.read(value.sessionId); } catch { /* an unreadable history starts the session fresh */ }
437
519
  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() };
438
520
  studioSessions.set(value.sessionId, session);
439
521
  }
440
522
  session.updatedAt = clock();
441
- const existing = session.turns.get(value.turnId);
442
- if (existing) { writeStudioStream(res, existing, 0, req); return true; }
443
- let current;
444
- try { current = studioRuntime?.readContext ? await studioRuntime.readContext(value.context.host) : await authoritativeStudioContext(value, hub); }
445
- catch (error) { if (error instanceof StudioProtocolError) throw error; if (error?.code) throw new StudioProtocolError(error.code, error.message); throw new StudioProtocolError("CAPABILITY_MISSING", "The connected editor does not expose authoritative Studio context."); }
446
- validateStudioContextFreshness(value.context, current);
447
523
  if (studioRuntime?.handleTurn) { await studioRuntime.handleTurn(value, req, res); return true; }
448
524
  const record = { next: 0, events: [], listeners: new Set(), terminal: false }; studioEvents.set(value.turnId, record); session.turns.set(value.turnId, record); session.host = studioIdentity(value.context.host);
449
525
  res.setHeader("set-cookie", `studio_owner=${encodeURIComponent(session.owner)}; Path=/agent; HttpOnly; SameSite=Strict`);
450
- const close = writeStudioStream(res, record, 0, req);
451
- const send = event => emitStudioEvent(value.turnId, event);
526
+ const close = writeStudioStream(res, record, 0);
527
+ // What the supervisor reviews once the turn is over: every frame but text
528
+ // and telemetry, and the last stretch of text the model wrote.
529
+ const turnFrames = [];
530
+ let replySegment = "", lastReply = "";
531
+ const send = event => {
532
+ if (event.type === "text.delta") { replySegment += event.text ?? ""; lastReply = replySegment; }
533
+ else if (!["execution_telemetry", "execution_tool_started"].includes(event.type)) {
534
+ if (event.type === "tool.start") replySegment = "";
535
+ turnFrames.push(event);
536
+ }
537
+ emitStudioEvent(value.turnId, event);
538
+ };
452
539
  // The Studio turn is measured exactly like the Workflow turn, correlated by
453
540
  // the turn id the host already owns (the frozen envelope carries no room
454
541
  // for a second one).
455
542
  const telemetry = createTurnTelemetry(send, value.turnId);
456
543
  let turnOutcome = "succeeded", turnFailureCode = null, toolFailed = false;
457
- const controller = new AbortController(); session.controller = controller;
544
+ const controller = new AbortController(); session.controller = controller; session.activeTurnId = value.turnId;
458
545
  // Admission is the document identity plus the exact scene revision each
459
546
  // command expects. Per-entity tokens are deliberately absent: the revision
460
547
  // already moves whenever any authored content changes, and a turn's second
461
548
  // edit to the same entity would otherwise carry a token the first edit retired.
462
549
  const admission = {
463
550
  commandId: () => randomUUID(), host: studioIdentity(value.context.host), revision: value.context.revision.scene,
464
- refresh: async () => {
465
- // Read at the admitted host: a scene action may have opened another
466
- // scene of this workspace during the turn (studio-tools adopts it).
551
+ // Read at the admitted host: a scene action may have opened another
552
+ // scene of this workspace during the turn (studio-tools adopts it).
553
+ snapshot: async () => {
467
554
  const host = { ...value.context.host, ...admission.host };
468
- const refreshed = studioRuntime?.readContext ? await studioRuntime.readContext(host) : await authoritativeStudioContext({ ...value, context: { ...value.context, host } }, hub);
555
+ return studioRuntime?.readContext ? await studioRuntime.readContext(host) : await authoritativeStudioContext({ ...value, context: { ...value.context, host } }, hub);
556
+ },
557
+ // Re-admits at the live revision and hands back the context it read, which
558
+ // the tools compare against what the agent last saw (studio-rebase.mjs).
559
+ refresh: async () => {
560
+ const refreshed = await admission.snapshot();
469
561
  admission.revision = refreshed.revision.scene;
562
+ return refreshed;
470
563
  },
471
564
  };
472
565
  // One motion generation per user message, whichever path starts it:
@@ -498,7 +591,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
498
591
  if (session.activeJobId === jobId) { session.activeJobId = null; session.activeJobTurnId = null; }
499
592
  }
500
593
  };
501
- 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) });
594
+ const tools = createStudioTools({ liveHub: hub, workspaceHandle: value.context.host.workspaceHandle, previsMode: value.context.scene?.previsMode, session: { signal: controller.signal, admission, generation, onJob, actionIndex: current?.actionIndex ?? [], baseline: value.context }, resolveImage: async (id, correlation) => hub.command("resolve_studio_image", { imageId: id, ...correlation }, value.context.host.workspaceHandle) });
502
595
  const motion = async args => {
503
596
  // Private artifact IDs belonged to the retired sidecar runtime. Reuse
504
597
  // retained editor takes through motion.loadVersion, not another installer.
@@ -519,12 +612,35 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
519
612
  return { ...result, visualStatus: visual.visualStatus, imageId: visual.imageId, revision: visual.revision, receiptId: visual.receiptId, ...(visual.dataUrl ? { dataUrl: visual.dataUrl } : {}) };
520
613
  },
521
614
  }));
615
+ const turnTransaction = agentTurn({ hub, context: current, turnId: value.turnId, admission });
616
+ const script = createScriptTool({ tools: modelTools, signal: controller.signal, emit: send });
617
+ const scriptTool = {
618
+ ...script,
619
+ // A script that fails after it edited the scene rolls the turn back, so
620
+ // the model retries from the pre-turn document, never from half a script.
621
+ handler: async (args, options) => {
622
+ const result = await script.handler(args, options);
623
+ if (result.ok || !result.calls.some(call => call.ok && Number.isSafeInteger(call.revision?.after) && call.revision.after !== call.revision.before)) return result;
624
+ const rollback = await turnTransaction.rollback();
625
+ return rollback ? { ...result, rollback: { status: "rolled_back", receiptId: rollback.receiptId, revision: rollback.revision, affectedIds: rollback.affectedIds.slice(0, 8) } } : result;
626
+ },
627
+ };
522
628
  let frameObservation;
523
629
  if (value.attachFrame) {
524
630
  const captured = await hub.command("capture_framing_png", {}, value.context.host.workspaceHandle);
525
631
  if (!captured?.dataUrl?.startsWith("data:image/")) throw new StudioProtocolError("TARGET_NOT_READY", "The current frame has no image bytes.");
526
632
  const match = /^data:([^;]+);base64,(.*)$/.exec(captured.dataUrl);
527
633
  frameObservation = { data: match[2], mimeType: match[1], revision: captured.revision, receiptId: captured.receiptId };
634
+ } else if (value.context.scene?.characterCount >= 2 || (value.context.shots?.length ?? 0) >= 1 || /샷|컷|카메라|커버리지|OTS|투샷|프레임|shot|camera|coverage|two-shot|framing/i.test(value.text || "")) {
635
+ // A staging or coverage request also sees the top-view plan; without it the turn just runs.
636
+ try {
637
+ const captured = await hub.command("capture_plan_png", {}, value.context.host.workspaceHandle);
638
+ const match = /^data:([^;]+);base64,(.*)$/.exec(captured?.dataUrl ?? "");
639
+ if (!match) throw new Error("The plan capture has no image bytes.");
640
+ frameObservation = { kind: "plan", data: match[2], mimeType: match[1], revision: captured.revision ?? value.context.revision?.scene ?? null, receiptId: captured.receiptId };
641
+ } catch (error) {
642
+ console.warn(`[agent] plan observation skipped: ${error.message}`);
643
+ }
528
644
  }
529
645
  const telemetryTools = new Map();
530
646
  const emitFrame = frame => {
@@ -539,6 +655,9 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
539
655
  toolFailed = true;
540
656
  if (typeof frame.error === "string" && frame.error.startsWith("Validation failed for tool ")) frame.error = "INVALID_ARGUMENT: Unexpected field.";
541
657
  }
658
+ // A command the sidecar re-sent once (stale revision, busy gesture) is
659
+ // marked on the frame itself; the receipt carries autoRebased and a warning.
660
+ if (frame.ok && frame.result?.autoRebased === true) frame.autoRebased = true;
542
661
  send(frame);
543
662
  if (record) {
544
663
  record.execution.executed(frame.ok ? "succeeded" : (controller.signal.aborted ? "cancelled" : "failed"));
@@ -554,10 +673,15 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
554
673
  if (frame.type === "done") {
555
674
  if (turnOutcome === "succeeded" && !toolFailed) telemetry.finished("succeeded", null);
556
675
  else telemetry.finished(turnOutcome, turnFailureCode || "tool_failed");
676
+ // Marked before the done frame leaves, so a client that asks for the
677
+ // review the moment its stream ends finds it pending, not absent.
678
+ // The author's own switch (value.supervisor === false) skips this turn's review.
679
+ if (supervisor && process.env.COZYCLAY_SUPERVISOR !== "off" && value.supervisor !== false && !controller.signal.aborted && shouldSupervise(turnFrames)) record.supervisor = "pending";
557
680
  }
558
681
  send(frame);
559
682
  };
560
683
  const persistenceMeta = { sceneName: value.context?.scene?.name ?? value.context?.sceneName ?? null, firstText: value.text, motionJobIds: [...session.motionJobIds] };
684
+ const modelKey = value.model || (fauxProvider ? `${fauxProvider.provider?.id || fauxProvider.provider || "faux"}/scripted` : "gpt-6-astra");
561
685
  let runner = studioRunners.get(value.sessionId);
562
686
  if (!runner) {
563
687
  runner = createAgentRunner({ models: await ensureWorkflowModels(), fauxProvider, sessionStore, clock, codexBaseUrl, cliproxyBaseUrl, auth, env });
@@ -565,10 +689,10 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
565
689
  }
566
690
  try {
567
691
  if (!session.modelSession) session.modelSession = await runner.openSession(value.sessionId, { surface: "studio" });
568
- for await (const frame of session.modelSession.start({
569
- surface: "studio", sessionId: value.sessionId, model: value.model || (fauxProvider ? `${fauxProvider.provider?.id || fauxProvider.provider || "faux"}/scripted` : "gpt-6-astra"), effort: value.effort,
692
+ for await (const frame of turnTransaction.run(input => session.modelSession.start(input))({
693
+ surface: "studio", sessionId: value.sessionId, model: modelKey, effort: value.effort,
570
694
  text: value.text, attachments: value.attachments, contextText: encodeStudioContext(value.context), frameObservation,
571
- context: value.context, tools: modelTools, signal: controller.signal, emit: send,
695
+ context: value.context, tools: [...modelTools, scriptTool], systemPrompt: value.context.scene?.previsMode === "storyboard" ? STUDIO_SYSTEM_PROMPT_STORYBOARD : undefined, signal: controller.signal, emit: send,
572
696
  meta: persistenceMeta,
573
697
  quotaEvent: headers => codex?.parseQuotaHeaders ? quotaEvent(codex, headers) : { type: "quota", plan: null, primary: { usedPercent: null, windowMinutes: null, resetAt: null }, credits: { has: null } },
574
698
  })) emitFrame(frame);
@@ -581,7 +705,25 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
581
705
  }
582
706
  if (controller.signal.aborted) { turnOutcome = "cancelled"; turnFailureCode = "aborted"; }
583
707
  if (!record.terminal) emitFrame({ type: "done" });
584
- close(); if (!res.writableEnded) res.end(); session.controller = null; return true;
708
+ close(); if (!res.writableEnded) res.end(); session.controller = null; session.activeTurnId = null;
709
+ // The review starts only now, after the stream has ended, and only ever
710
+ // appends one note to the record: it neither delays nor steers the turn.
711
+ if (record.supervisor === "pending") void (async () => {
712
+ const startedAt = Date.now();
713
+ let frame;
714
+ try {
715
+ frame = await runSupervisor({ registry: await ensureWorkflowModels(), modelKey, effort: process.env.COZYCLAY_SUPERVISOR_EFFORT || "high", turnId: value.turnId, request: value.text, frames: turnFrames, reply: lastReply,
716
+ hub, workspaceHandle: value.context.host.workspaceHandle, admission, baseline: value.context, actions: current?.actionIndex ?? [] });
717
+ } catch (error) {
718
+ frame = { type: "supervisor", turnId: value.turnId, verdict: "unavailable", model: modelKey, effort: null, elapsedMs: Date.now() - startedAt, summary: "", items: [], issues: [], fixes: [], droppedFixes: [], counts: { blocker: 0, concern: 0, note: 0, fixes: 0 }, reason: "The supervisor could not start." };
719
+ }
720
+ // COZYCLAY_SUPERVISOR_AUTOFIX=blocker (#743) only marks a note whose
721
+ // blockers carry fixes; turns stay the panel's to start, never the sidecar's.
722
+ if (process.env.COZYCLAY_SUPERVISOR_AUTOFIX === "blocker" && frame.fixes?.some(fix => fix.severity === "blocker")) frame = { ...frame, autofix: { eligible: true, severity: "blocker" } };
723
+ record.supervisor = "done";
724
+ emitStudioEvent(value.turnId, frame);
725
+ })();
726
+ return true;
585
727
  };
586
728
  const handle = async (req, res, path = new URL(req.url, "http://127.0.0.1").pathname) => {
587
729
  if (!path.startsWith("/agent/")) return false;
@@ -595,7 +737,34 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
595
737
  if (!record || !session || parseCookies(req).studio_owner !== session.owner) { json(res, 403, { error: { code: "AUTH_REQUIRED", message: "Studio event stream is not owned by this session." } }); return true; }
596
738
  const after = Number(new URL(req.url, "http://127.0.0.1").searchParams.get("after") || 0);
597
739
  if (!Number.isSafeInteger(after) || after < 0) { json(res, 400, { error: "invalid cursor" }); return true; }
598
- writeStudioStream(res, record, after, req); return true;
740
+ writeStudioStream(res, record, after); return true;
741
+ }
742
+ // The supervisor note for one Studio turn (#728): 200 with the note once it
743
+ // exists, held up to SUPERVISOR_WAIT_MS while it is pending, 204 when the
744
+ // turn was never reviewed or the wait ran out.
745
+ if (path.startsWith("/agent/turn/") && path.endsWith("/supervisor") && req.method === "GET") {
746
+ if (!await hasAnyCredential()) { json(res, 401, { error: { code: "AUTH_REQUIRED", message: "Sign in or configure a provider key." } }); return true; }
747
+ const turnId = decodeURIComponent(path.slice("/agent/turn/".length, -"/supervisor".length));
748
+ const session = [...studioSessions.values()].find(candidate => candidate.turns.has(turnId)); const record = studioEvents.get(turnId);
749
+ if (!record || !session) { json(res, 404, { error: "turn not found" }); return true; }
750
+ if (parseCookies(req).studio_owner !== session.owner) { json(res, 403, { error: { code: "AUTH_REQUIRED", message: "Studio supervisor note is not owned by this session." } }); return true; }
751
+ const noContent = () => { res.writeHead(204, { "cache-control": "no-store" }); res.end(); };
752
+ const existing = record.events.findLast(event => event.type === "supervisor");
753
+ if (existing) { json(res, 200, existing); return true; }
754
+ if (record.supervisor !== "pending") { noContent(); return true; }
755
+ await new Promise(resolve => {
756
+ let timer;
757
+ const settle = note => {
758
+ clearTimeout(timer); record.listeners.delete(listener); res.off("close", onClose);
759
+ if (note !== undefined && !res.writableEnded && !res.destroyed) { if (note) json(res, 200, note); else noContent(); }
760
+ resolve();
761
+ };
762
+ const listener = event => { if (event.type === "supervisor") settle(event); };
763
+ const onClose = () => settle(undefined);
764
+ record.listeners.add(listener); res.once("close", onClose);
765
+ timer = setTimeout(() => settle(null), SUPERVISOR_WAIT_MS);
766
+ });
767
+ return true;
599
768
  }
600
769
  if (path.startsWith("/agent/turn/") && path.endsWith("/steer") && req.method === "POST") {
601
770
  const turnId = decodeURIComponent(path.slice("/agent/turn/".length, -"/steer".length));
@@ -604,12 +773,15 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
604
773
  value = await readBody(req, TURN_BODY_LIMIT);
605
774
  if (!value || typeof value.text !== "string" || !value.text.trim() || !validAttachments(value.attachments)) throw new Error("Invalid request.");
606
775
  } catch { json(res, 400, { error: "invalid request" }); return true; }
607
- // Studio turnIds are frozen envelopes; their events are keyed in studioSessions,
608
- // never in the Workflow `sessions` map, so that lookup alone tells them apart.
609
- if ([...studioSessions.values()].some(candidate => candidate.turns.has(turnId))) { json(res, 409, { error: { code: "STEER_UNSUPPORTED", message: "Steering a Studio turn is not supported." } }); return true; }
610
- const session = [...sessions.values()].find(candidate => candidate.turnId === turnId);
776
+ // Studio turns are keyed in studioSessions, never in the Workflow
777
+ // `sessions` map, so that lookup alone tells them apart. Only the cookie
778
+ // owner of the Studio session may steer its running turn.
779
+ const studio = [...studioSessions.values()].find(candidate => candidate.turns.has(turnId));
780
+ if (studio && parseCookies(req).studio_owner !== studio.owner) { json(res, 403, { error: { code: "AUTH_REQUIRED", message: "Studio turn is not owned by this session." } }); return true; }
781
+ const session = studio ?? [...sessions.values()].find(candidate => candidate.turnId === turnId);
611
782
  if (!session) { json(res, 404, { error: "turn not found" }); return true; }
612
- if (!session.running || !session.modelSession) { json(res, 409, { error: { code: "NO_ACTIVE_TURN", message: "This turn already ended." } }); return true; }
783
+ const active = studio ? studio.activeTurnId === turnId && studio.controller && !studio.controller.signal.aborted : session.running;
784
+ if (!active || !session.modelSession) { json(res, 409, { error: { code: "NO_ACTIVE_TURN", message: "This turn already ended." } }); return true; }
613
785
  const images = (Array.isArray(value.attachments) ? value.attachments : []).map(attachment => {
614
786
  const match = /^data:([^;]+);base64,(.*)$/.exec(attachment?.dataUrl || "");
615
787
  return match ? { type: "image", data: match[2], mimeType: match[1] } : null;
@@ -672,7 +844,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
672
844
  let value;
673
845
  try {
674
846
  value = await readBody(req, IMAGE_BODY_LIMIT);
675
- if (typeof value.prompt !== "string" || !value.prompt.trim() || typeof value.imageDataUrl !== "string" || !value.imageDataUrl.startsWith("data:image/") || (value.referenceDataUrl !== undefined && (typeof value.referenceDataUrl !== "string" || !value.referenceDataUrl.startsWith("data:image/"))) || !validReferences(value.references) || (value.quality !== undefined && !["auto", "low", "medium", "high"].includes(value.quality))) throw new Error("Invalid request.");
847
+ if (typeof value.prompt !== "string" || !value.prompt.trim() || typeof value.imageDataUrl !== "string" || !value.imageDataUrl.startsWith("data:image/") || (value.referenceDataUrl !== undefined && (typeof value.referenceDataUrl !== "string" || !value.referenceDataUrl.startsWith("data:image/"))) || !validReferences(value.references) || (value.composition !== undefined && !["frame", "sheet"].includes(value.composition)) || (value.quality !== undefined && !["auto", "low", "medium", "high"].includes(value.quality)) || (value.size !== undefined && !IMAGE_SIZES.includes(value.size))) throw new Error("Invalid request.");
676
848
  } catch { json(res, 400, { error: "invalid request" }); return true; }
677
849
  if (!await auth.getAccessToken()) { json(res, 401, { error: { code: "auth", message: "Sign in with ChatGPT in the Agent panel." } }); return true; }
678
850
  try {
@@ -681,8 +853,17 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
681
853
  // Scene references (#167) are appended after the frame/reference pair,
682
854
  // and the prompt says what each attachment is for.
683
855
  const references = Array.isArray(value.references) ? value.references : [];
684
- const prompt = `${value.prompt}${await renderGuidance(value.prompt)}${referenceGuidance(references)}`;
685
- const result = await codex.editImage({ ...value, prompt, extraImages: references.map((entry) => entry.dataUrl) });
856
+ // composition "frame": the attached frame was framed by hand (Pose mode), so the shot
857
+ // camera's description would contradict it; the frame alone owns camera and pose.
858
+ // composition "sheet" (Sculpt blueprint): a model sheet, not a shot, so no scene or
859
+ // camera guidance is added; the prompt is sent as written.
860
+ const prompt = value.composition === "sheet"
861
+ ? value.prompt
862
+ : value.composition === "frame"
863
+ ? `${FRAME_COMPOSITION_GUIDANCE}\n${value.prompt}${referenceGuidance(references)}`
864
+ : `${value.prompt}${await renderGuidance(value.prompt)}${referenceGuidance(references)}`;
865
+ const { composition: _composition, ...request } = value;
866
+ const result = await codex.editImage({ ...request, prompt, extraImages: references.map((entry) => entry.dataUrl) });
686
867
  json(res, 200, { dataUrl: `data:image/png;base64,${result.pngBase64}`, width: result.width, height: result.height });
687
868
  } catch (error) { json(res, error.status === 401 ? 401 : 502, { error: errorInfo(error) }); }
688
869
  return true;
@@ -40,7 +40,7 @@ export function classifyProviderError(message) {
40
40
  return "upstream";
41
41
  }
42
42
 
43
- export function extractStatus(message) {
43
+ function extractStatus(message) {
44
44
  const match = /\b(401|403|429|5\d\d)\b/.exec(message);
45
45
  return match ? Number(match[1]) : undefined;
46
46
  }
@@ -145,11 +145,90 @@ function studioUserMessage(input) {
145
145
  };
146
146
  }
147
147
 
148
+ const STUDIO_CONTEXT_PREFIX = "<studio-context>";
149
+ const STUDIO_CONTEXT_BLOCK = /^<studio-context>\n([\s\S]*?)\n<\/studio-context>/;
150
+ const STUDIO_CONTEXT_SUPERSEDED = "<studio-context superseded/>";
151
+ const escapeStudioJson = (value) => JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026");
152
+ const isStudioContextPart = (part) => part?.type === "text" && typeof part.text === "string" && part.text.startsWith(STUDIO_CONTEXT_PREFIX);
153
+
154
+ function withoutInspectContext(part) {
155
+ if (part?.type !== "text" || typeof part.text !== "string") return part;
156
+ let parsed;
157
+ try { parsed = JSON.parse(part.text); } catch { return part; }
158
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Object.hasOwn(parsed, "context")) return part;
159
+ const { context: _context, ...rest } = parsed;
160
+ return { ...part, text: JSON.stringify(rest) };
161
+ }
162
+
163
+ /** The Studio per-request context diet (#716), applied to the provider view
164
+ * only (pi's transform_context): every <studio-context> but the latest is
165
+ * replaced by a superseded marker, every inspect_studio result but the most
166
+ * recent tool result drops its re-embedded `context`, and the latest
167
+ * context's static actionIndex moves to the end of the system prompt. The
168
+ * stored lane messages are never touched; edited messages are new objects. */
169
+ function studioContextDiet({ messages, systemPrompt }) {
170
+ let latestContext = -1;
171
+ let latestToolResult = -1;
172
+ messages.forEach((message, index) => {
173
+ if (message?.role === "user" && Array.isArray(message.content) && message.content.some(isStudioContextPart)) latestContext = index;
174
+ if (message?.role === "toolResult") latestToolResult = index;
175
+ });
176
+ let actionIndex;
177
+ const edited = messages.map((message, index) => {
178
+ if (message?.role === "toolResult" && message.toolName === "inspect_studio" && index !== latestToolResult && Array.isArray(message.content)) {
179
+ const content = message.content.map(withoutInspectContext);
180
+ return content.some((part, i) => part !== message.content[i]) ? { ...message, content } : message;
181
+ }
182
+ if (message?.role !== "user" || !Array.isArray(message.content) || !message.content.some(isStudioContextPart)) return message;
183
+ const content = message.content.map((part) => {
184
+ if (!isStudioContextPart(part)) return part;
185
+ const block = STUDIO_CONTEXT_BLOCK.exec(part.text);
186
+ if (index !== latestContext) return { ...part, text: block ? STUDIO_CONTEXT_SUPERSEDED + part.text.slice(block[0].length) : STUDIO_CONTEXT_SUPERSEDED };
187
+ if (!block) return part;
188
+ let context;
189
+ try { context = JSON.parse(block[1]); } catch { return part; }
190
+ if (!context || typeof context !== "object" || !Array.isArray(context.actionIndex)) return part;
191
+ ({ actionIndex } = context);
192
+ const { actionIndex: _actionIndex, ...rest } = context;
193
+ return { ...part, text: `${STUDIO_CONTEXT_PREFIX}\n${escapeStudioJson(rest)}\n</studio-context>${part.text.slice(block[0].length)}` };
194
+ });
195
+ return { ...message, content };
196
+ });
197
+ return {
198
+ messages: edited,
199
+ systemPrompt: actionIndex ? `${systemPrompt}\n<actions>\n${JSON.stringify(actionIndex)}\n</actions>` : systemPrompt,
200
+ };
201
+ }
202
+
203
+ /** Anthropic prompt-cache breakpoints on the stable prefix (#716): pi-ai's
204
+ * anthropic-messages builder already marks the system blocks, the last tool
205
+ * and the last user block by default; this only fills a missing mark on the
206
+ * last system block and the last tool, never past the API's 4 breakpoints,
207
+ * and never on tools for a model whose compat declares them unsupported. */
208
+ export function withAnthropicCacheBreakpoints(payload, model) {
209
+ if (!payload || typeof payload !== "object") return undefined;
210
+ const marked = (block) => Boolean(block?.cache_control);
211
+ const system = Array.isArray(payload.system) ? payload.system : [];
212
+ const tools = Array.isArray(payload.tools) ? payload.tools : [];
213
+ const messageBlocks = (Array.isArray(payload.messages) ? payload.messages : []).flatMap((message) => Array.isArray(message?.content) ? message.content : []);
214
+ let count = [...system, ...tools, ...messageBlocks].filter(marked).length;
215
+ const cacheControl = { type: "ephemeral" };
216
+ let next = payload;
217
+ if (system.length && !marked(system.at(-1)) && count < 4) {
218
+ next = { ...next, system: [...system.slice(0, -1), { ...system.at(-1), cache_control: cacheControl }] };
219
+ count += 1;
220
+ }
221
+ if (tools.length && !marked(tools.at(-1)) && model?.compat?.supportsCacheControlOnTools !== false && count < 4) {
222
+ next = { ...next, tools: [...tools.slice(0, -1), { ...tools.at(-1), cache_control: cacheControl }] };
223
+ }
224
+ return next === payload ? undefined : next;
225
+ }
226
+
148
227
  function studioObservationMessage(observation) {
149
228
  return {
150
229
  role: "user",
151
230
  content: [
152
- { type: "text", text: `Studio frame observation revision ${JSON.stringify(observation?.revision ?? null)} receipt ${observation?.receiptId ?? "unavailable"}` },
231
+ { type: "text", text: `Studio ${observation?.kind === "plan" ? "plan observation (top view)" : "frame observation"} revision ${JSON.stringify(observation?.revision ?? null)} receipt ${observation?.receiptId ?? "unavailable"}` },
153
232
  { type: "image", data: observation.data, mimeType: observation.mimeType },
154
233
  ],
155
234
  };
@@ -311,6 +390,18 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
311
390
  streamOptions: { maxRetryDelayMs: RETRY_MAX_DELAY_MS, ...(codexBaseUrl ? { transport: "sse" } : {}) },
312
391
  }, state.context)).harness;
313
392
  state.harness.hooks.on("after_response", (response) => emitQuota(state.active?.queue, state.lastInput, response));
393
+ state.harness.hooks.on("after_response", ({ message }) => {
394
+ const usage = state.active?.usage;
395
+ if (!usage) return;
396
+ usage.requests += 1;
397
+ for (const key of ["input", "output", "cacheRead", "cacheWrite"]) if (Number.isFinite(message?.usage?.[key])) usage[key] += message.usage[key];
398
+ });
399
+ state.harness.hooks.on("transform_context", (event) => state.lastInput?.surface === "studio" ? studioContextDiet(event) : undefined);
400
+ state.harness.hooks.on("before_payload", ({ model, payload }) => {
401
+ if (model?.api !== "anthropic-messages") return undefined;
402
+ const next = withAnthropicCacheBreakpoints(payload, model);
403
+ return next ? { payload: next } : undefined;
404
+ });
314
405
  state.harness.hooks.on("after_response", async ({ message }) => {
315
406
  if (message.stopReason !== "error" || state.provider !== "openai-codex" || state.active.authRetried
316
407
  || classifyError({ message: message.errorMessage }).code !== "unauthorized") return;
@@ -327,6 +418,9 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
327
418
  for (const message of restored.history) await state.lane.appendMessage(message, state.context);
328
419
  state.persisted = restored.history.length;
329
420
  }
421
+ // Studio only: older turns' full contexts are stale and large, so each request
422
+ // carries the newest one whole and a stub for the rest. The stored lane and
423
+ // the persisted history keep every context.
330
424
  state.unsubscribers.push(state.harness.events.on("turn_end", persist));
331
425
  state.unsubscribers.push(state.harness.events.on("run_end", persist));
332
426
  }
@@ -405,7 +499,7 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
405
499
  const fallback = code === "aborted" ? "The turn was aborted." : code === "truncated" ? "The model stream ended before a terminal response event." : "The model or live editor could not complete this turn.";
406
500
  queue.push({ type: "error", code, message: detail || fallback, ...(status ? { status } : {}) });
407
501
  }
408
- queue.push({ type: "done" });
502
+ queue.push({ type: "done", usage: { ...state.active.usage } });
409
503
  queue.close();
410
504
  });
411
505
  return () => { for (const unsubscribe of eventUnsubscribers) unsubscribe(); };
@@ -420,7 +514,7 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
420
514
  const queue = new FrameQueue();
421
515
  // Studio never sends a quota frame at all (unchanged, #379): mark it sent
422
516
  // up front so `pushFrame`'s gate never holds Studio's first frame.
423
- state.active = { queue, quotaSent: input.surface === "studio", pendingFrames: [], authRetried: false, abortRequested: false, lastResponseHeaders: null };
517
+ state.active = { queue, quotaSent: input.surface === "studio", pendingFrames: [], authRetried: false, abortRequested: false, lastResponseHeaders: null, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 } };
424
518
  state.lastInput = input;
425
519
  const controller = input.signal ? null : new AbortController();
426
520
  const signal = input.signal || controller.signal;